Ustawienia sieciowe z FreeRadius przez DHCP

Ustawienia sieciowe z FreeRadius przez DHCP
Otrzymaliśmy zadanie skonfigurowania przydzielania adresów IP dla abonentów. Warunki zadania:

  • Nie przekażemy dedykowanego serwera do autoryzacji — musicie sobie poradzić 😉
  • Abonenci powinni otrzymywać ustawienia sieciowe przez DHCP
  • Sieć jest heterogeniczna. To zarówno urządzenia PON, jak i zwykłe switche z ustawioną Opcją 82 oraz punkty WiFi z bazami.
  • Jeśli żadne z warunków przydzielania IP nie jest spełnione — należy przydzielić IP z sieci «gościnnej»

Z dobrych wieści: jest coś takiego serwer na FreeBSD, które może «działać», ale jest «za oceanem» ;), nie «bezpośrednio w tej sieci».

Jest też znakomite urządzenie Mikrotik. Ogólny schemat sieci jest mniej więcej taki:

Ustawienia sieciowe z FreeRadius przez DHCP

Po chwili namysłu podjęto decyzję o użyciu FreeRadius do przydzielania ustawień sieciowych abonentom. W zasadzie schemat jest standardowy: na Microtick włączamy serwer DHCP, na nim również klien Nord Radius. Ustawiamy połączenie serwer DHCP -> Klient Radius -> Serwer Radius.

Wygląda na niezbyt skomplikowane. Ale! Diabeł tkwi w szczegółach. A konkretnie:

  • Podczas autoryzacji PON OLT w tym schemacie na FreeRadius przychodzi zapytanie z User-Name równym adresowi MAC głównej stacji, Agent-Circuit-Id równym MAC PON Onu i pustym hasłem.
  • Podczas autoryzacji ze switchy z opcją 82, na FreeRadius przychodzi zapytanie z pustym User-Name równym adresowi MAC urządzenia abonenta oraz wypełnionymi dodatkowymi atrybutami Agent-Circuit-Id oraz Agent-Remote-Id, które zawierają odpowiednio adres MAC switcha relacyjnego oraz port, do którego podłączony jest abonent.
  • Część abonentów z punktów WiFi autoryzuje się przez protokoły PAP-CHAP
  • Część abonentów z punktów WiFi autoryzuje się z User-Name równym adresowi MAC punktu WiFi, bez hasła.

Informacje historyczne: co to jest «Option 82» w DHCP

To dodatkowe opcje protokołu DHCP, które umożliwiają przekazywanie dodatkowych informacji, na przykład w polach Agent-Circuit-Id oraz Agent-Remote-Id. Zwykle używa się ich do przekazywania adresu MAC switcha relacyjnego oraz portu, do którego podłączony jest abonent. W przypadku sprzętu PON lub bazowych stacji WIFI pole Agent-Circuit-Id nie niesie użytecznych informacji (brak portu abonenta). W tym przypadku ogólny schemat działania DHCP wygląda następująco:

Ustawienia sieciowe z FreeRadius przez DHCP

Krok po kroku ten schemat działa w ten sposób:

  1. Sprzęt abonenta wysyła broadcastowe zapytanie DHCP o ustawienia sieciowe
  2. Urządzenie (takie jak switch, punkt dostępowy WiFi lub PON), do którego bezpośrednio podłączone jest sprzęt abonenta, „przechwytuje” ten pakiet i zmienia go, wprowadzając dodatkowe opcje Option 82 oraz adres IP agenta przekazu, a następnie przesyła go dalej w sieci.
  3. Serwer DHCP przyjmuje żądanie, formułuje odpowiedź i przesyła ją do urządzenia relacyjnego.
  4. Urządzenie relacyjne przekazuje pakiet odpowiedzi do urządzenia abonenta.

Oczywiście, wszystko nie działa tak prosto, potrzebna jest odpowiednia konfiguracja sprzętu sieciowego.

Instalacja FreeRadius

Konfiguracja ustawień FreeRadius pozwala osiągnąć wszystkie cele, ale jest to skomplikowane i niejasne… zwłaszcza gdy zajrzy się tam po N miesiącach „wszystko działa”. Dlatego podjęto decyzję, aby napisać własny moduł autoryzacji dla FreeRadius w Pythonie. Dane do autoryzacji będziemy pobierać z bazy MySQL. Opisywanie jej struktury nie ma sensu, i tak każdy zrobi to „pod siebie”. W szczególności zająłem się strukturą zaproponowaną z modułem sql dla FreeRadius i nieco ją zmodyfikowałem, dodając pole mac oraz port dla każdego abonenta, oprócz loginu i hasła.

Zatem, na początek instalujemy FreeRadius:

cd /usr/ports/net/freeradius3
make config
make
install clean

W ustawieniach zaznaczamy do instalacji:

Ustawienia sieciowe z FreeRadius przez DHCP

Tworzymy symlink do modułu python (czyli „włączamy” go):

ln -s /usr/local/etc/raddb/mods-available/python /usr/local/etc/raddb/mods-enabled

Zainstalujemy dodatkowy moduł dla Pythona:

pip install mysql-connector

W ustawieniach modułu python dla FreeRadius należy wpisać ścieżki wyszukiwania modułów w zmienną python_path. Na przykład, u mnie to:

python_path="/usr/local/etc/raddb/mods-config/python:/usr/local/lib/python2.7:/usr/local/lib/python27.zip:/usr/local/lib/python2.7:/usr/local/lib/python2.7/plat-freebsd12:/usr/local/lib/python2.7/lib-tk:/usr/local/lib/python2.7/lib-old:/usr/local/lib/python2.7/lib-dynload:/usr/local/lib/python2.7/site-packages"

Ścieżki można poznać, uruchamiając interpreter Pythona i wpisując polecenia:

root@phaeton:/usr/local/etc/raddb/mods-enabled# python
Python 2.7.15 (domyślny, 8 grudnia 2018, 01:22:25) 
[GCC 4.2.1 Compatible FreeBSD Clang 6.0.1 (tags/RELEASE_601/final 335540)] na freebsd12
Wpisz "help", "copyright", "credits" lub "license", aby uzyskać więcej informacji.
>>> import sys
>>> sys.path
['', '/usr/local/lib/python27.zip', '/usr/local/lib/python2.7', '/usr/local/lib/python2.7/plat-freebsd12', '/usr/local/lib/python2.7/lib-tk', '/usr/local/lib/python2.7/lib-old', '/usr/local/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/site-packages']
>

Jeśli nie podejmiesz tego kroku, skrypty napisane w Pythonie uruchomione w FreeRadius nie odnajdą modułów, które są wymienione w import. Ponadto należy odkomentować w ustawieniach modułu funkcje wywołania autoryzacji i księgowości. Na przykład mój moduł wygląda następująco:

python {
    python_path="/usr/local/etc/raddb/mods-config/python:/usr/local/lib/python2.7:/usr/local/lib/python2.7/site-packages:/usr/local/lib/python27.zip:/usr/local/lib/python2.7:/usr/local/lib/python2.7/plat-freebsd12:/usr/local/lib/python2.7/lib-tk:/usr/local/lib/python2.7/lib-old:/usr/local/lib/python2.7/lib-dynload:/usr/local/lib/python2.7/site-packages"
    module = work
    mod_instantiate = ${.module}
    mod_detach = ${.module}

    mod_authorize = ${.module}
    func_authorize = authorize

    mod_authenticate = ${.module}
    func_authenticate = authenticate

    mod_preacct = ${.module}
    func_preacct = preacct

    mod_accounting = ${.module}
    func_accounting = accounting

    mod_checksimul = ${.module}
    mod_pre_proxy = ${.module}
    mod_post_proxy = ${.module}
    mod_post_auth = ${.module}
    mod_recv_coa = ${.module}
    mod_send_coa = ${.module}

}

Skrypt work.py (i wszystkie inne) należy umieścić w /usr/local/etc/raddb/mods-config/python. Łącznie mam trzy skrypty.

work.py:

#!/usr/local/bin/python
# coding=utf-8
import radiusd
import func
import sys
from pprint import pprint

mysql_host="localhost"
mysql_username="укацук"
mysql_password="ыукаыукаыук"
mysql_base="ыукаыкуаыу"

def instantiate(p):
  print ("*** instantiate ***")
  print (p)
  # return 0 for success or -1 for failure

def authenticate(p):
    print ("*** Аутенфикация!!***")
    print (p)
def authorize(p):
  radiusd.radlog(radiusd.L_INFO, '*** radlog call in authorize ***')    
  conn=func.GetConnectionMysql(mysql_host, mysql_username, mysql_password, mysql_base);
  param=func.ConvertArrayToNames(p);
  pprint(param)
  print ("*** Авторизация ***")
  reply = ()
  conf = ()
  cnt=0
  username="";mac="";
  # сначала проверяем "как положено", по связке логин/пароль
  if ("User-Name" in param) and ("User-Password" in param) :
      print ("Вариант авторизации (1): есть логин-пароль")
      pprint(param["User-Name"])
      pprint(param["User-Password"])
      pprint(conn)
      print(sys.version_info)
      print (radiusd.config)
      sql="select radreply.attribute,radreply.value from radcheck inner join radreply on radreply.username=radcheck.username where radcheck.username=%s and radcheck.value=%s"
      print(sql)
      cursor = conn.cursor(dictionary=True,buffered=True)
      cursor.execute(sql,[param["User-Name"], param["User-Password"]]);
      row = cursor.fetchone()	
      while row is not None:    
                cnt=cnt+1
                username=row["username"]
                reply = reply+((str(row["attribute"]),str(row["value"])), )
                row = cursor.fetchone()	          
 # вариант, что User-Name - это МАС адрес БС,пароля и порта нет                
  if ("User-Name" in param)  and ("User-Password" in param) and (cnt==0):
    if param["User-Password"] =='':
        if ":" in param["User-Name"]:
              pprint(param["User-Name"])            
              print ("Вариант авторизации (2): User-Name - это MAC адрес базовой станции, порта и пароля нет")
              sql="select radreply.username,radreply.attribute,radreply.value from radcheck inner join radreply on radreply.username=radcheck.username where REPLACE(radcheck.mac,':','') = REPLACE(REPLACE('"+str(param["User-Name"])+"','0x',''),':','') and radcheck.sw_port=''"
              print (sql)
              cursor = conn.cursor(dictionary=True,buffered=True)
              cursor.execute(sql);
              row = cursor.fetchone()	
              while row is not None:                  
                        cnt=cnt+1
                        username=row["username"]
                        mac=param["User-Name"]
                        reply = reply+((str(row["attribute"]),str(row["value"])), )
                        row = cursor.fetchone()	          
  if ("Agent-Remote-Id" in param)  and ("User-Password" in param) and (cnt==0):
    if param["User-Password"] =='':
              pprint(param["Agent-Remote-Id"])            
              print ("Вариант авторизации (2.5): Agent-Remote-Id - это MAC адрес PON оборудования")
              sql="select radreply.username,radreply.attribute,radreply.value from radcheck inner join radreply on radreply.username=radcheck.username where REPLACE(radcheck.mac,':','') = REPLACE(REPLACE('"+str(param["Agent-Remote-Id"])+"','0x',''),':','') and radcheck.sw_port=''"
              print (sql)
              cursor = conn.cursor(dictionary=True,buffered=True)
              cursor.execute(sql);
              row = cursor.fetchone()	
              while row is not None:                  
                        cnt=cnt+1
                        username=row["username"]
                        mac=param["User-Name"]
                        reply = reply+((str(row["attribute"]),str(row["value"])), )
                        row = cursor.fetchone()	          
                        
#Вариант, что Agent-Remote-Id - это МАС адрес БС,пароля и порта нет и предыдущие варианты поиска IP результата не дали                
  if ("Agent-Remote-Id" in param)  and ("User-Password" not in param) and (cnt==0):
          pprint(param["Agent-Remote-Id"])            
          print ("Вариант авторизации (3): Agent-Remote-Id - МАС базовой станции/пон. Порта в биллинге нет")
          sql="select radreply.username,radreply.attribute,radreply.value from radcheck inner join radreply on radreply.username=radcheck.username where REPLACE(radcheck.mac,':','') = REPLACE(REPLACE('"+str(param["Agent-Remote-Id"])+"','0x',''),':','') and radcheck.sw_port=''"
          print(sql)
          cursor = conn.cursor(dictionary=True,buffered=True)
          cursor.execute(sql);
          row = cursor.fetchone()	
          while row is not None:    
                    cnt=cnt+1
                    mac=param["Agent-Remote-Id"]
                    username=row["username"]
                    reply = reply+((str(row["attribute"]),str(row["value"])), )
                    row = cursor.fetchone()	          
#Вариант, что предыдущие попытки результата не дали, но есть Agent-Remote-Id и Agent-Circuit-Id
  if ("Agent-Remote-Id" in param)  and ("Agent-Circuit-Id" in param) and (cnt==0):
          pprint(param["Agent-Remote-Id"])            
          pprint(param["Agent-Circuit-Id"])            
          print ("Вариант авторизации (4): авторизация по Agent-Remote-Id и Agent-Circuit-Id, в биллинге есть порт/мак")
          sql="select radreply.username,radreply.attribute,radreply.value from radcheck inner join radreply on radreply.username=radcheck.username where upper(radcheck.sw_mac)=upper(REPLACE('"+str(param["Agent-Remote-Id"])+"','0x','')) and upper(radcheck.sw_port)=upper(RIGHT('"+str(param["Agent-Circuit-Id"])+"',2)) and radcheck.sw_port<>''"
          print(sql)
          cursor = conn.cursor(dictionary=True,buffered=True)
          cursor.execute(sql);
          row = cursor.fetchone()	
          while row is not None:    
                    cnt=cnt+1
                    mac=param["Agent-Remote-Id"]
                    username=row["username"]
                    reply = reply+((str(row["attribute"]),str(row["value"])), )
                    row = cursor.fetchone()	          
 # если так до сих пор IP не получен, то выдаю иего из гостевой сети..
  if cnt==0:      
      print ("Ни один из вариантов авторизации не сработал, получаю IP из гостевой сети..")
      ip=func.GetGuestNet(conn)      
      if ip!="": 
          cnt=cnt+1;
          reply = reply+(("Framed-IP-Address",str(ip)), )
 # если совсем всё плохо, то Reject
  if cnt==0:
    conf = ( ("Auth-Type", "Reject"), ) 
  else:
    #если авторизация успешная (есть такой абонент), то запишем историю авторизации
    if username!="":
            func.InsertToHistory(conn,username,mac, reply);
    conf = ( ("Auth-Type", "Accept"), )             
    pprint (reply)
  conn=None;
  return radiusd.RLM_MODULE_OK, reply, conf

def preacct(p):
  print ("*** preacct ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def accounting(p):
  print ("*** Аккаунтинг ***")
  radiusd.radlog(radiusd.L_INFO, '*** radlog call in accounting (0) ***')  
  print (p)
  conn=func.GetConnectionMysql(mysql_host, mysql_username, mysql_password, mysql_base);
  param=func.ConvertArrayToNames(p);
  pprint(param)  
  print("Удалим старые сессии (более 20 минут нет аккаунтинга)");
  sql="delete from radacct where TIMESTAMPDIFF(minute,acctupdatetime,now())>20"
  cursor = conn.cursor(dictionary=True,buffered=True)
  cursor.execute(sql);
  conn.commit()
  print("Обновим/добавим информацию о сессии")
  if (("Acct-Unique-Session-Id" in param) and ("User-Name" in param) and ("Framed-IP-Address" in param)):
      sql='insert into radacct (radacctid,acctuniqueid,username,framedipaddress,acctstarttime) values (null,"'+str(param['Acct-Unique-Session-Id'])+'","'+str(param['User-Name'])+'","'+str(param['Framed-IP-Address'])+'",now()) ON DUPLICATE KEY update acctupdatetime=now()'
      print(sql)
      cursor = conn.cursor(dictionary=True,buffered=True)
      cursor.execute(sql)
      conn.commit()
  conn=None;
  return radiusd.RLM_MODULE_OK

def pre_proxy(p):
  print ("*** pre_proxy ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def post_proxy(p):
  print ("*** post_proxy ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def post_auth(p):
  print ("*** post_auth ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def recv_coa(p):
  print ("*** recv_coa ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def send_coa(p):
  print ("*** send_coa ***")
  print (p)
  return radiusd.RLM_MODULE_OK

def detach():
  print ("*** На этом всё детишечки ***")
  return radiusd.RLM_MODULE_OK

func.py:

#!/usr/bin/python2.7
# coding=utf-8

import mysql.connector
from mysql.connector import Error

# Функция возвращает соединение с MySQL
def GetConnectionMysql(mysql_host, mysql_username, mysql_password, mysql_base):    
    try:
        conn = mysql.connector.connect(host=mysql_host,database=mysql_base,user=mysql_username,password=mysql_password)
        if conn.is_connected(): print('---cоединение с БД '+mysql_base+' установлено')
    except Error as e:
        print("Ошибка: ",e);
        exit(1);       
    return conn

def ConvertArrayToNames(p):
    mass={};
    for z in p:
      mass[z[0]]=z[1]
    return mass
# Функция записывает историю соединения по известным данным
def InsertToHistory(conn,username,mac, reply):
    print("--записываю для истории")
    repl=ConvertArrayToNames(reply)
    if "Framed-IP-Address" in repl:
        sql='insert into radpostauth (username,reply,authdate,ip,mac,session_id,comment) values ("'+username+'","Access-Accept",now(),"'+str(repl["Framed-IP-Address"])+'","'+str(mac)+'","","")'
        print(sql)
        cursor = conn.cursor(dictionary=True,buffered=True)          
        cursor.execute(sql);
        conn.commit()
# Функция выдает последний по дате выдачи IP адрес из гостевой сети        
def GetGuestNet(conn):
    ip="";id=0
    sql="select * from guestnet order by dt limit 1"
    print (sql)
    cursor = conn.cursor(dictionary=True,buffered=True)          
    cursor.execute(sql);
    row = cursor.fetchone()	
    while row is not None:    
            ip=row["ip"]
            id=row["id"]
            row = cursor.fetchone()	          
    if id>0:
        sql="update guestnet set dt=now() where id="+str(id)
        print (sql)
        cursor = conn.cursor(dictionary=True,buffered=True)          
        cursor.execute(sql);
        conn.commit()
    return ip         

radiusd.py:

#!/usr/bin/python2.7
# coding=utf-8

# from modules.h

RLM_MODULE_REJECT = 0
RLM_MODULE_FAIL = 1
RLM_MODULE_OK = 2
RLM_MODULE_HANDLED = 3
RLM_MODULE_INVALID = 4
RLM_MODULE_USERLOCK = 5
RLM_MODULE_NOTFOUND = 6
RLM_MODULE_NOOP = 7
RLM_MODULE_UPDATED = 8
RLM_MODULE_NUMCODES = 9

# from log.h
L_AUTH = 2
L_INFO = 3
L_ERR = 4
L_WARN = 5
L_PROXY = 6
L_ACCT = 7

L_DBG = 16
L_DBG_WARN = 17
L_DBG_ERR = 18
L_DBG_WARN_REQ = 19
L_DBG_ERR_REQ = 20

# log function
def radlog(level, msg):
    import sys
    sys.stdout.write(msg + 'n')
    level = level

Jak widać w kodzie, staramy się zidentyfikować abonenta na wszelkie dostępne sposoby na podstawie jego znanych adresów MAC lub kombinacji opcji 82, a jeśli to się nie udaje, to przydzielamy najstarszy z dotychczas używanych adresów IP z "gościnnej" sieci. Pozostało skonfigurować skrypt default w folderze sites-enabled, aby potrzebne funkcje ze skryptu Pythona były wywoływane w określonych momentach. W rzeczywistości wystarczy dostosować plik do postaci:

default

server default {
listen {
    type = auth
    ipaddr = *
    port = 0
    limit {
          max_connections = 16
          lifetime = 0
          idle_timeout = 30
    }
}
listen {
    ipaddr = *
    port = 0
    type = acct
    limit {
    }
}

listen {
    type = auth
    port = 0
    limit {
          max_connections = 1600
          lifetime = 0
          idle_timeout = 30
    }
}

listen {
    ipv6addr = ::
    port = 0
    type = acct
    limit {
    }
}

authorize {
    python
    filter_username
    preprocess
    expiration
    logintime
}

authenticate {
    Auth-Type PAP {
	pap
	python
    }
    Auth-Type CHAP {
	chap
	python
    }
    Auth-Type MS-CHAP {
	mschap
	python
    }
    eap
}


preacct {
    preprocess
    acct_unique
    suffix
    files
}

accounting {
    python
    exec
    attr_filter.accounting_response
}

session {

}

post-auth {
    update {
	&reply: += &session-state:
    }
    exec
    remove_reply_message_if_eap
    Post-Auth-Type REJECT {
attr_filter.access_reject
	eap
remove_reply_message_if_eap
    }
    Post-Auth-Type Challenge {
    }
}

pre-proxy {
}

post-proxy {
    eap
}
}

Próbujemy uruchomić i zobaczyć, co trafia do dziennika debugowania:

/usr/local/etc/rc.d/radiusd debug

Co więcej, podczas konfiguracji FreeRadius wygodnie jest testować jego działanie za pomocą narzędzia radclient. Na przykład autoryzacja:

echo "User-Name=4C:5E:0C:2E:7F:15,Agent-Remote-Id=0x9845623a8c98,Agent-Circuit-Id=0x00010006" | radclient -x  127.0.0.1:1812 auth testing123

Lub księgowość:

echo "User-Name=4C:5E:0C:2E:7F:15,Agent-Remote-Id=0x00030f26054a,Agent-Circuit-Id=0x00010002" | radclient -x  127.0.0.1:1813 acct testing123

Chcę ostrzec, że stosowanie podobnych schematów i skryptów "bez zmian" w "przemysłowych" skali jest zupełnie niedopuszczalne. Widać to przynajmniej po:

  • możliwości "fałszowania" adresu MAC. Wystarczy, że abonent zapisze sobie cudzy MAC i pojawią się problemy
  • logika przydzielania sieci gościnnych jest poniżej jakiejkolwiek krytyki. Nie ma nawet sprawdzenia, "czy mogą być już klienci z przydzielonym takim adresem IP?"

To po prostu "rozwiązanie z kolan", aby działało konkretnie w moich warunkach, nic więcej. Nie oceniajcie surowo 😉

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster