su: Uwierzytelnienie nie powiodło się

Na jednym VPS-ie z Debianem 6 nie mogłem przejść na konto roota korzystając z polecenia su. Próba jego wykonania kończyła się tak:

joe@dejiko:~$ su -
Hasło:
su: Uwierzytelnienie nie powiodło się

W /var/log/auth.log pojawiały się takie wpisy:

Feb 21 10:48:30 dejiko unix_chkpwd[4123]: check pass; user unknown
Feb 21 10:48:30 dejiko unix_chkpwd[4123]: password check failed for user (root)
Feb 21 10:48:30 dejiko su[4122]: pam_unix(su:auth): authentication failure; logname=joe uid=1000 euid=1000 tty=/dev/pts/1 ruser=joe rhost=  user=root

Początkowo myślałem, że problem tkwi w tym, że użytkownik joe nie należy do grupy sudoers, ale ten trop okazał się mylny. W wyniku śledztwa okazało się, że winne są uprawnienia pliku /bin/su. Na działającym systemie wyglądały one tak:

-rwsr-xr-x 1 root root 34024 2011-02-15  /bin/su

zaś na problematycznej maszynie tak:

-rwxr-xr-x 1 root root 34024 2011-02-15  /bin/su

Innymi słowy brakowało bitu „set UID”, więc problem rozwiązałem poleceniem:

chmod u+s /bin/su

Na inkryminowanym systemie ten sam problem dotyczył polecenia passwd, objawy były następujące:

joe@dejiko:~$ passwd
Zmienianie hasła dla joe.
(obecne) hasło UNIX:
passwd: Błąd podczas modyfikowania tokenu uwierzytelniania
passwd: password unchanged

Tu również pomógł chmod.

Lighttpd na kilku portach

Ostatnio przenosiłem serwer Icecast2 na osobną maszynkę i, co ważniejsze, na osobną domenę. Ponieważ trochę słuchaczy miało zapamiętane URL-e do radia chciałem ich bezboleśnie przekierować na nowy adres. W tym celu przygotowałem taką regułkę w Lighttpd:

$SERVER["socket"] == ":8000" {
	url.redirect = ("^/(.*)" => "http://stream.anime.com.pl:8000/$1")
}

Po zatrzymaniu Icecasta i zrestartowaniu Lighttpd wszystkie żądania HTTP wysyłane na port 8000 (na którm działał Icecast) są przekierowywane na nowy serwer. Dzięki temu słuchacze wciąż mogą używać starych URL-i.

Jednocześnie ten przykład pokazuje jak skłonić Lighty do nasłuchiwania na kilku portach jednocześnie. Można to wykorzystać np. po to, żeby uruchomić testową wersję serwisu na porcie 81 obok wersji produkcyjnej działającej na domyślnym porcie 80.

PS. Icecast2 ma funkcję przenoszenia słuchaczy, ale z tego co zrozumiałem działa ona tylko w obrębie jednego serwera. Czyli można ich przenieść na inny strumień na tym samym serwerze, ale na inny serwer już nie.

Połączenia HTTP z PHP za serwerem proxy

Dzisiaj zmierzyłem się z problemem wywoływania usługi SOAP z poziomu PHP działającego na serwerze schowanym za proxy. Ostatecznie okazało się to prostsze niż myślałem, wystarczy przekazać dodatkowe parametry do konstruktora obiektu SoapClient.

$soap = new SoapClient('http://domena.pl/usluga?wsdl', array(
  'proxy_host' => '10.107.10.69',
  'proxy_port' => 8080,
  'proxy_login' => 'nazwa.uzytkownika',
  'proxy_password' => 'haslo'
  ));

Zabrało mi to jednak trochę czasu bo zacząłem od szukania rozwiązania na niższym poziomie czyli modyfikacji parametrów używanych przez PHP do wykonywania połączeń HTTP. Metodą guglania, prób i błędów dotarłem do takiego rozwiązania, które również działa.

$default = stream_context_get_default(array(
  'http' => array(
    'proxy' => 'tcp://10.107.10.69:8080',
    'request_fulluri' => true,
    'header' => 'Proxy-Authorization: Basic ' . base64_encode('nazwa.uzytkownika:haslo'),
    )
  ));

Dzięki tej metodzie z proxy zaczynają współpracować wszystkie funkcje wykorzystujące protokół HTTP, np. readfile() z plikiem na zdalnym serwerze.

readfile('http://www.google.com/');

Wyłączanie akcji w generatorze Symfony

Generator modułów administracyjnych w Symfony nie umożliwia wybrania akcji, które dany moduł ma wykonywać, zawsze dostajemy pełen zestaw CRUD. Czasami zachodzi potrzeba zablokowania niektórych akcji, np. w wypadku logów bezpieczeństwa sens ma jedynie index (przeglądanie listy), a jakakolwiek modyfikacja powinna być zabroniona.

Odnośniki widoczne na liście można łatwo ukryć edytując plik generator.yml, wystarczy domyślne akcje zastąpić pustymi listami.

generator:
  param:
    config:
      list:
        title:             Permission changes log
        actions:           {}
        object_actions:    {}
        batch_actions:     {}
      filter:
        display:           [agent_user_name, subject_user_name, permission_name, created_at]

Akcje można jednak dalej wykonywać ręcznie wpisując URL, dlatego należy je zablokować w kontrolerze.

class permission_logActions extends autoPermission_logActions
{
  public function preExecute()
  {
    $this->forward404If($this->getActionName() !== 'index', 'Action disabled');
    parent::preExecute();
  }
}

Zapisywanie osadzonych formularzy w Symfony

Zapisywanie osadzonych formularzy w Symfony działa inaczej niż intuicyjnie zakładałem. Co ciekawsze, już raz się z tym kiedyś zetknąłem, ale było to na tyle dawno, że zapomniałem rozwiązania.

Załóżmy, że w zwykłym formularzu dziedziczącym z klasy sfForm osadzam formularz oparty na modelu z bazy danych czyli dziedziczący z sfFormDoctrine.

class OnePageCheckoutForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['same_address'] = new sfWidgetFormInputCheckbox();
    $this->validatorSchema['same_address'] = new sfValidatorBoolean();

    $this->embedForm('customer', new CustomerForm());
    $this->embedForm('shipment_address', new AddressForm());
    $this->getWidgetSchema()->setNameFormat('checkout_form[%s]');
  }
}

W kontrolerze chcę zapisać dane z osadzonych formularzy, ale oczywiście klasa OnePageCheckoutForm nie ma metody save(). W pierwszym odruchu napisałem mniej więcej coś takiego:

public function executeCheckout(sfWebRequest $request)
{
  if (!$this->getUser()->hasCart())
  {
    return $this->redirect('@cart');
  }
  $this->cart = $this->getUser()->getCart();

  $this->form = new OnePageCheckoutForm();
  if ($request->getMethod() == 'POST')
  {
    $this->form->bind($request->getParameter($this->form->getName()), $request->getFiles($this->form->getName()));
    if ($this->form->isValid())
    {
      $values = $this->form->getValues();
      $this->form->getEmbeddedForm('customer')->save();
      $this->form->getEmbeddedForm('shipment_address')->save();

      $this->cart->setCustomerId($this->form->getEmbeddedForm('customer')->getObject()->getId());
      $this->cart->save();
      $this->redirect('@cart_payment_process');
    }
  }
}

Ku memu zaskoczeniu zobaczyłem wyjątek, z którego wynikało, że zapis formularza customer się nie powiódł, bo formularz nie zawiera danych. Dla pewności wrzuciłem w kod kilka var_dumpów:

// wynik: dane dla całego formularza łącznie z osadzonymi
var_dump($this->form->getValues());
// wynik: false
var_dump($this->form->getEmbeddedForm('customer')->isBound());
// wynik: pusta tablica
var_dump($this->form->getEmbeddedForm('customer')->getObject()->toArray());

Jak się okazało, formularz klasy sfForm nie aktualizuje obiektów w osadzonych formularzach, trzeba to wykonać samemu:

$this->form->getEmbeddedForm('customer')
  ->updateObject($this->form->getValue('customer'));

Można to zrobić w kontrolerze, ja ze względów konwencjonalno-estetycznych dopisałem do klasy OnePageCheckoutForm metody updateEmbeddedForms() i saveEmbeddedForms(), które są z grubsza kopią tak samo nazwanych metod z klasy sfFormObject.

Na poły wirtualne środowisko developerskie

Wymiana dysku systemowego na SSD była dla mnie okazją do wprowadzenia zmian w domowym Linuksie. Jedną z nich było postawienie zupełnie nowego środowiska developerskiego. Z powodzeniem używam go już od niemal dwóch miesięcy i jestem z niego zadowolony, więc żywię skromną nadzieję, że opisując je może komuś pomogę.

Przez długie lata jeden system służył mi do Internetu, programowania, rozrywki i wszelkich innych zastosowań. Miałem na nim zainstalowany komplet przeglądarek, wybór baz danych (m.in. PostgreSQL, MySQL, MongoDB), trochę serwerów (Lighttpd, ProFTPd, IceCast), interpretery i kompilatory różnych języków programowania (PHP, Python, g++…) plus sporo różnych narzędzi. Takie rozwiązanie ma kilka wad:

  • Na desktopie mam przeważnie nowsze wersje programów niż na serwerze produkcyjnym, więc czasami pojawiają się problemy z kompatybilnością.
  • Bazy, serwery i inne bajery pożerają zasoby nawet jeśli ich w danej chwili nie potrzebuję, bo nie programuję.
  • Kompilowanie programów ze źródeł, instalowanie dziwnych paczek, grzebanie w konfiguracji programów nawet przy zachowaniu staranności prędzej czy później tworzy w systemie bajzel, w efekcie którego rzeczy przestają działać lub działają w osobliwy sposób.
  • Mam tylko jedno środowisko, żeby spróbować kod z inną wersją PHP albo innym serwerem www muszę uciekać się do karkołomnych/upierdliwych konfiguracji.

Czytaj dalej „Na poły wirtualne środowisko developerskie”

Sortowanie wyników z użyciem funkcji w Solarium

To będzie zwięzły wpis dokumentujący małe zwycięstwo w pojedynku z Solarium. Zadanie polegało na dodaniu nowego sposobu sortowania produktów: w pierwszej kolejności według dostępności produktu (bez względu na ilość, tylko binarne tak/nie), w drugiej według ceny. W bazie Solr mamy tylko ilość sztuk danego produktu, należało ją więc jakoś zrzutować na wartość binarną. Na szczęście Solr umożliwia użycie funkcji do porządkowania znalezionych dokumentów, Solarium od drugiej wersji także to obsługuje. Końcowy efekt moich starań jest następujący:

$query->addSort($query->getHelper()->functionCall('min', array('stock', 1)), Solarium_Query_Select::SORT_DESC);

Dla celów dydaktycznych dodam, że najpierw próbowałem zrobić to w ten sposób:

$query->addSort($query->getHelper()->functionCall('if', array('stock', 1, 0)), Solarium_Query_Select::SORT_DESC);

jednak kończyło się to błędem:

Solr HTTP error: sort param could not be parsed as a query, and is not a field that exists in the index: if(stock,1,0) (400)

Przyczyna okazała się prozaiczna, ale odkrycie jej zajęło mi dłuższą chwilę. Otóż funkcja if dostępna jest dopiero od wersji 4.0 Solr, a my używamy 3.6.

Generowanie losowych kodów rabatowych

Wczoraj pisałem moduł obsługujący kody rabatowe w sklepie internetowym. Wedle specyfikacji kody mogą być jedno- lub wielokrotnego użytku. Te pierwsze mogą być hurtowo generowane w panelu administracyjnym. Na chłopski rozum taki jednorazowy kod powinien być:

  • losowy, żeby pomysłowi klienci nie mogli zgadywać kodów
  • w miarę krótki, żeby wpisywanie go nie było kłopotliwe
  • pozbawiony podobnych do siebie znaków takich jak O i 0, ze względów wspomnianych w poprzednim punkcie

Mój pomysł na generowanie takich kodów wygląda następująco:

public static function generateRandom()
{
  return str_pad(strtr(strtoupper(base_convert(rand(1000, 2176782335), 10, 36)), '0O', '1A'), 6, 'X');
}

Powyższa metoda generuje losowe kody złożone z 6 alfanumerycznych znaków (wielkie litery i cyfry) z pominięciem zera i litery O. Przykładowy kod: 3LFPHP. Tytułem wyjaśnienia: 2176782335 to ZZZZZZ w systemie trzydziestoszóstkowym.

Nie robiłem żadnych testów, ale przeczucie mówi mi, że takie rozwiązanie jest szybsze niż losowanie po jednym znaku z tablicy dozwolonych znaków.

Kody powinny być także unikalne, a powyższa metoda tego nie zapewnia. Gdyby tych kodów trzeba było generować tysiące na minutę pewnie pokusiłbym się o jakiś bardziej wyszukany algorytm, np. w jednej transakcji wygenerować tablicę z liczbą kodów ciut większą niż zadana, jednym zapytaniem sprawdzić które z nich już występują w bazie, odfiltrować je z tablicy, a resztę wstawić drugim zapytaniem. W tym konkretnym sklepie nie ma takiej potrzeby, więc wybrałem prostsze rozwiązanie.

public static function generateCodes(Voucher $voucher, $number)
{
  for ($i = 1; $i <= $number; $i++)
  {
    do
    {
      try
      {
        $code = new VoucherCode();
        $code->setVoucher($voucher)
          ->setCode(VoucherCode::generateRandom())
          ->save();
      }
      catch (Doctrine_Connection_Mysql_Exception $e)
      {
      }
    } while ($code->isNew())
  }
}

Kolumna code w tabeli ma założony unikalny indeks (przydaje się do wyszukiwania), więc próba wstawienia takiej samej wartości powoduje wyjątek, który łapię i generuję nowe wartości dopóki wstawianie rekordu do tablicy nie zakończy się powodzeniem.

PLAIN stupid czyli jak zostałem spamerem

Dzisiaj będzie o głupim błędzie w konfiguracji Exima, który wynikł z bezrefleksyjnego kopiowania i wklejania opublikowanych w Internecie przykładowych konfiguracji.

Ze zdziwieniem przeczytałem maila z serwerowni informującego, że z mojego serwera jest wysyłany spam. Dołączony w załączniku mail wyglądał na wysłany z mojego serwera, a w logach Exima znalazłem informację o wysłaniu inkryminowanej wiadomości, więc o pomyłce serwerowni nie było mowy.

2012-06-22 09:34:16 1ShyNj-0000KH-Cx <= harun@digit0.com H=server177288-1.santrex.net [37.59.45.10] P=esmtpa A=plain_pgsql:sales S=3593 id=73D566338398i-49A3A971B01x025-29B991E@PHRSBS
2012-06-22 09:34:18 1ShyNj-0000KH-Cx => altairhosttest@hotmail.com R=dnslookup T=remote_smtp H=mx2.hotmail.com [65.54.188.94]
2012-06-22 09:34:18 1ShyNj-0000KH-Cx Completed

Z powyższego wycinka logów wynika, że spamer z adresu 37.59.45.10 z powodzeniem autoryzował się jako użytkownik sales przy użyciu autoryzacji plain_pgsql. Stosowna regułka wyglądała tak:

plain_pgsql:
   driver = plaintext
   public_name = PLAIN
   server_condition = ${if eq{$auth3}{${lookup pgsql{SELECT password FROM exim_virtual_users WHERE login = '${quote_pgsql:$auth2}'}}}{yes}{no}}
   server_set_id = $auth2
   server_prompts = :

Powyższy mechanizm wyszukuje w bazie dane użytkownika o podanym loginie ($auth2) i uzyskane w ten sposób hasło porównuje z hasłem podanym przez użytkownika ($auth3). Gdzie tkwi problem? Otóż powyższy mechanizm działa świetnie dla użytkowników, którzy istnieją w bazie. Jeśli jednak sprytny spamer poda nieistniejącego użytkownika i puste hasło to autoryzacja przebiega pomyślnie! Dzieje się tak ponieważ dla nieistniejącego użytkownika zwracane jest puste hasło, a puste hasło = puste hasło.

Dokładnie ten przypadek został opisany w dokumentacji Exima, więc sobie ku pamięci i wszystkim zainteresowanym powtarzam: RTFM!

Dla porządku podaję jeszcze poprawną wersję regułki autoryzującej:

plain_pgsql:
   driver = plaintext
   public_name = PLAIN
   server_condition = ${if and{{!eq{$auth2}{}} {!eq{$auth3}{}} {eq{$auth3}{${lookup pgsql{SELECT password FROM exim_virtual_users WHERE login = '${quote_pgsql:$auth2}'}}}}} {yes}{no}}
   server_set_id = $auth2
   server_prompts = :

Ciasteczka w mod_rewrite

Dotychczas pisząc regułki dla mod_rewrite operowałem tylko na URL-ach. Ostatnio w pracy przy okazji wdrażania nowej wersji strony musiałem przekierować adresy starych stron na nowe. Trudność polegała na tym, że w starej wersji jeden URL generował trzy wersje językowe, a język jest przechowywany w ciasteczku. Na szczęście mod_rewrite może operować na wielu elementach żądania HTTP, w tym na ciasteczkach. Po kilku minutach czytania doszedłem do takiego wyniku:

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=fr [OR]
RewriteCond %{HTTP_COOKIE} !^.*LANG.*$
RewriteRule ^/5/index\.cfm$ /5/tester-gratuitement? [NC,R=301,L]

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=nl
RewriteRule ^/5/index\.cfm$ /5/test-gratis? [NC,R=301,L]

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=en
RewriteRule ^/5/index\.cfm$ /5/test-for-free? [NC,R=301,L]

Pierwszy zestaw dotyczy francuskiej a zarazem domyślnej wersji strony. Istotne są druga i trzecia instrukcja RewriteCond, które odpowiednio sprawdzają czy ciasteczko LANG ma wartość fr lub czy w ogóle nie jest ustawione. Następująca po nich instrukcja RewriteRule przekierowuje do nowej francuskiej strony. Tu warto dodać, że mod_rewrite operuje na ciasteczkach w takiej postaci w jakiej są przesyłane w nagłówku HTTP Cookie czyli napisu rozdzielonego średnikami.