Misskeyを使って内輪向けのクローズドなSNSを構築してみようとしたけどうまくいかなかった話
クローズドなSNSというと、部活やサークルではLineのグループ機能やDiscordなどのチャットツール、会社ではMicrosoft365やGoogle Workspaceといったようなグループウェアを利用することが多いかと思います。
ただ、上記のようなツールだと雰囲気が堅苦しくなったり、逆に内輪ノリになり過ぎて発言しにくくなる人が出たりもしてしまうので、Twitterのようなタイムラインを搭載したミニブログ形式のSNSの方が場合によっては優れている場面もあるのではないかと思いました。
そこで今回はTwitterライクなSNSとして、Misskeyが利用できないか検証してみました。
要件としては以下を想定しています。
- スマホからも利用するためIP制限などはかけない
- 外部の人間からは投稿を見えないようにする
- リモート(他のサーバ)とActivityPubでの連携はしない
- なるべく工数はかけない
MisskeyはActivityPubを採用した分散型SNSのため、クローズドな運用をするのは本来の用途ではなく、意外と上記要件を満たすのが面倒でした。
結論から言うと今回は完全に非公開にすることができなかったのですが、同じようなことを考えている人がもし万が一いれば、本記事が参考になれば幸いです。
なお、Misskeyを構築する部分については本記事では割愛しますが、基本的に以下手順を参考に構築しています。
環境は以下CloudformationでUbuntu Server 22.04 LTSを構築し、ALBをHTTPS終端にしています。
Route53にホストゾーンさえ登録されていれば利用可能です。
(昔書いたものを流用しているのでSSMではなく直接EC2にパブリックIPを付与してしまっています)
Cloudformation.yml
AWSTemplateFormatVersion: "2010-09-09"
Description:
Misskey CloudFormation
Metadata:
"AWS::CloudFormation::Interface":
ParameterGroups:
- Label:
default: "Project Name Prefix"
Parameters:
- PJPrefix
- Label:
default: "Network Configuration"
Parameters:
- VPCCIDR
- PublicSubnetACIDR
- PublicSubnetCCIDR
- Label:
default: "EC2 Configration"
Parameters:
- EC2Name
- AMIImage
- InstanceType
- MyIP
- EC2VolumeType
- EC2VolumeSize
- Label:
default: "Route53 Configration"
Parameters:
- HostZoneName
- HostName
- Label:
default: "ALB Configration"
Parameters:
- InternetALBName
- AllowWebAccessIP
- Label:
default: "ACM Configration"
Parameters:
- HostedZoneId
ParameterLabels:
VPCCIDR:
default: "VPC CIDR"
PublicSubnetACIDR:
default: "PublicSubnetA CIDR"
PublicSubnetCCIDR:
default: "PublicSubnetC CIDR"
EC2Name:
default: "EC2 Instance Name"
AMIImage:
default: "Use EC2 AMI Image Default Ubuntu Server 22.04 LTS"
InstanceType:
default: "InstanceType"
MyIP:
default: "Your IP Address with CIDR"
EC2VolumeType:
default: "EC2 Instance Volume Type"
EC2VolumeSize:
default: "EC2 Instance Volume Size GiB"
HostZoneName:
default: "Domain HostZone Name"
HostName:
default: "HostName"
InternetALBName:
default: "Internet ALB Name"
AllowWebAccessIP:
default: "Allow Web Access IP with CIDR"
HostedZoneId:
default: "Domain HostedZone ID"
Parameters:
PJPrefix:
Type: String
VPCCIDR:
Type: String
Default: "192.168.0.0/24"
PublicSubnetACIDR:
Type: String
Default: "192.168.0.0/27"
PublicSubnetCCIDR:
Type: String
Default: "192.168.0.32/27"
EC2Name:
Type: String
Default: misskey
AMIImage:
Type: String
Default: ami-0ac6b9b2908f3e20d
InstanceType:
Type: String
Default: t3a.medium
MyIP:
Type: String
Default: 0.0.0.0/32 #自身の環境のグローバルIPに書き換えてください
EC2VolumeType:
Type: String
Default: gp3
EC2VolumeSize:
Type: Number
Default: 20
HostZoneName:
Type: String
Default: example.com #自身の環境のホストゾーン名に書き換えてください
HostName:
Type: String
Default: misskey #任意のホスト名に書き換えてください
InternetALBName:
Type: String
Default: alb
AllowWebAccessIP:
Type: String
Default: 0.0.0.0/0
HostedZoneId:
Type: String
Default: XXXXXXXXXXXXXXXXX #自身の環境のホストゾーンIDに書き換えてください
Resources:
# VPC
VPC:
Type: "AWS::EC2::VPC"
Properties:
CidrBlock: !Ref VPCCIDR
EnableDnsSupport: "true"
EnableDnsHostnames: "true"
InstanceTenancy: default
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-vpc"
# InternetGateway
InternetGateway:
Type: "AWS::EC2::InternetGateway"
Properties:
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-igw"
# attach
InternetGatewayAttachment:
Type: "AWS::EC2::VPCGatewayAttachment"
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC
# Public SubnetA
PublicSubnetA:
Type: "AWS::EC2::Subnet"
Properties:
AvailabilityZone: "ap-northeast-1a"
CidrBlock: !Ref PublicSubnetACIDR
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-public-subnet-a"
# Public SubnetC
PublicSubnetC:
Type: "AWS::EC2::Subnet"
Properties:
AvailabilityZone: "ap-northeast-1c"
CidrBlock: !Ref PublicSubnetCCIDR
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-public-subnet-c"
# Public RouteTableA
PublicRouteTableA:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-public-route-a"
# Public RouteTableC
PublicRouteTableC:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-public-route-c"
# PublicRouteA
PublicRouteA:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref PublicRouteTableA
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref InternetGateway
# PublicRouteC
PublicRouteC:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref PublicRouteTableC
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref InternetGateway
PublicSubnetARouteTableAssociation:
Type: "AWS::EC2::SubnetRouteTableAssociation"
Properties:
SubnetId: !Ref PublicSubnetA
RouteTableId: !Ref PublicRouteTableA
PublicSubnetCRouteTableAssociation:
Type: "AWS::EC2::SubnetRouteTableAssociation"
Properties:
SubnetId: !Ref PublicSubnetC
RouteTableId: !Ref PublicRouteTableC
# EC2
EC2:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref AMIImage
KeyName: !Sub "${PJPrefix}-key"
InstanceType: !Ref InstanceType
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeType: !Ref EC2VolumeType
DeleteOnTermination: true
VolumeSize: !Ref EC2VolumeSize
NetworkInterfaces:
- DeleteOnTermination: True
DeviceIndex: "0"
SubnetId: !Ref PublicSubnetA
GroupSet:
- !Ref EC2SG
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-${EC2Name}"
# Keypair
Keypair:
Type: AWS::EC2::KeyPair
Properties:
KeyFormat: pem
KeyName: !Sub "${PJPrefix}-key"
KeyType: ed25519
# ElasticIP
ElasticIP:
Type: "AWS::EC2::EIP"
Properties:
Domain: vpc
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-eip"
ElasticIPAssociate:
Type: AWS::EC2::EIPAssociation
Properties:
AllocationId: !GetAtt ElasticIP.AllocationId
InstanceId: !Ref EC2
#SG
EC2SG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: !Sub "${PJPrefix}-EC2-SG"
GroupDescription: Allow SSH access
VpcId: !Ref VPC
SecurityGroupIngress:
# ssh
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: !Ref MyIP
#ALB
EC2SGIngress:
Type: "AWS::EC2::SecurityGroupIngress"
Properties:
IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !GetAtt [ ALBSG, GroupId ]
GroupId: !GetAtt [ EC2SG, GroupId ]
ALBSG:
Type: "AWS::EC2::SecurityGroup"
Properties:
VpcId: !Ref VPC
GroupName: !Sub "${PJPrefix}-alb-sg"
GroupDescription: "-"
Tags:
- Key: "Name"
Value: !Sub "${PJPrefix}-alb-sg"
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: !Ref AllowWebAccessIP
# A Record Alias
DNSRecordSet:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneName: !Sub "${HostZoneName}."
Name: !Sub "${HostName}.${HostZoneName}."
Type: A
AliasTarget:
DNSName: !GetAtt InternetALB.DNSName
HostedZoneId: !GetAtt InternetALB.CanonicalHostedZoneID
# Target Group
TargetGroup:
Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
Properties:
VpcId: !Ref VPC
Name: !Sub "${PJPrefix}-${InternetALBName}-tg"
Protocol: HTTP
Port: 80
HealthCheckProtocol: HTTP
HealthCheckPath: "/"
HealthCheckPort: "traffic-port"
HealthyThresholdCount: 5
UnhealthyThresholdCount: 2
HealthCheckTimeoutSeconds: 5
HealthCheckIntervalSeconds: 30
Matcher:
HttpCode: 200
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-${InternetALBName}-tg"
TargetGroupAttributes:
- Key: "deregistration_delay.timeout_seconds"
Value: 300
- Key: "stickiness.enabled"
Value: false
- Key: "stickiness.type"
Value: lb_cookie
- Key: "stickiness.lb_cookie.duration_seconds"
Value: 86400
Targets:
- Id: !Ref EC2
Port: 80
# Internet ALB
InternetALB:
Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
Properties:
Name: !Sub "${PJPrefix}-${InternetALBName}-alb"
Tags:
- Key: Name
Value: !Sub "${PJPrefix}-${InternetALBName}-alb"
Scheme: "internet-facing"
LoadBalancerAttributes:
- Key: "deletion_protection.enabled"
Value: false
- Key: "idle_timeout.timeout_seconds"
Value: 300
SecurityGroups:
- !Ref ALBSG
Subnets:
- !Sub "${PublicSubnetA}"
- !Sub "${PublicSubnetC}"
ALBListener:
Type: "AWS::ElasticLoadBalancingV2::Listener"
Properties:
DefaultActions:
- TargetGroupArn: !Ref TargetGroup
Type: forward
LoadBalancerArn: !Ref InternetALB
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Ref ACM
# ACM
ACM:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: !Sub "${HostName}.${HostZoneName}"
DomainValidationOptions:
- DomainName: !Sub "${HostName}.${HostZoneName}"
HostedZoneId: !Ref HostedZoneId
ValidationMethod: DNS
クローズドにするにあたっての問題点
Misskeyをクローズドに運用するにあたって、以下の問題に直面しました。
外部サーバから情報がみえてしまう
普通にMisskeyのサーバを構築した状態だと、外部のサーバから投稿を見たりフォローしたりができてしまいます。
以下は自身のMisskey.ioのアカウントから、今回構築した検証サーバのユーザを検索した結果です。
分かりにくいと思いますが、ActivityPubを利用して検証サーバのユーザ情報を正常に取得できています。
ただし、今回は取得されてしまうと困るので、接続を拒否する必要があります。
また、もし外部のサーバとうっかり連合してしまうと、サーバ内の情報が連合先のサーバにpostされてしまうらしいので、連合しないような設定も行う必要があります。
アカウントを持っていない人でもホーム画面からサーバ内情報を確認できる
画像の通り、ログインをしなくてもトップ画面からタイムラインが筒抜けです。
上記についてはベースロールからLTL、GTLを無効化することで簡単に表示を消せるのですが、同じ画面から「サーバー情報」をクリックすると、ユーザーやチャンネルなどは結局見えてしまいます。
アカウントを持っていない人でも「サーバ名/timeline」や「サーバ名/search」で検索をかけるとサーバ内情報を確認できる
先ほどと同じような問題なのですが、例えば以下のようにhttps://サーバ名/search
で検索をかけると、検索画面に入れてしまいますし、そのままノートやユーザーの検索などもできてしまいます。
APIを使うとサーバ内の情報を引っ張ってこれてしまう
APIを利用することで、一部情報を引っ張ってくることが可能です。
以下のリファレンスにも記載の通り、APIの認証キーが無くても持ってこれる情報があります。
例えばノートの情報は「Credential required」が No となっているため、アカウントを持っていなくても以下のAPIを叩けば誰でも最新の投稿を取得できます。
$ curl -s https://サーバ名/api/notes -H 'content-type: application/json' --data-raw "{\"limit\":10}" | jq -r .[0].user.name,.[0].text
pessi
検証環境Misskeyへのテスト投稿!!!
試してみた対応
サーバの連合を無効化する
- 管理者アカウントで
コントロールパネル - 設定 - 全般 - 連合
を「なし」に設定
- Misskeyの設定ファイルから、proxyに存在しないプロキシを設定する
# Proxy for HTTP/HTTPS - #proxy: http://127.0.0.1:3128 + proxy: http://127.0.0.1:3128
上記にて、リモートサーバとの連合はできなくなっているはずです。
外部から情報を見えないようにする
NginxでBasic認証有効化
ログイン画面からでも内部の情報を取得できてしまうため、不格好ですがBasic認証を有効化してアカウントを知っている人以外はログイン画面を開けないようにします。
(認証目的ではなく画面隠し目的でBasic認証を利用)
sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd 【ユーザ名】
nginx設定ファイルの修正(/etc/nginx/conf.d/misskey.conf)
~~~(略)~~~
location / {
+ auth_basic "Auth";
+ auth_basic_user_file "/etc/nginx/.htpasswd";
~~~(略)~~~
この状態でアクセスを行うと、Basic認証画面を表示させることに成功しました。
しかし、Basic認証後のページ読み込みでエラーが表示されます。
どうやらAPIがBasic認証に失敗して必要な情報を持ってこれずエラーとなるようです。
APIでBasic認証を回避する
location / の設定をコピーしてlocation /api を作成し、こちらにはベーシック認証の設定を記載しないようにします。
ついでにエラーがでていたので、manifest.jsonも追加しておきます。
~~~(略)~~~
location / {
+ auth_basic "Auth";
+ auth_basic_user_file "/etc/nginx/.htpasswd";
~~~(略)~~~
}
+ location /api {
- auth_basic "Auth";
- auth_basic_user_file "/etc/nginx/.htpasswd";
~~~(以下 "location /" と同様の設定)~~~
+ location /manifest.json {
- auth_basic "Auth";
- auth_basic_user_file "/etc/nginx/.htpasswd";
~~~(以下 "location /" と同様の設定)~~~
こうすることで、ログイン画面はBasic認証で隠しつつ、一度Basic認証にさえ通れば通常の画面が見えるようになります。
しかし、これではAPIがBasic認証を回避してしまうので、先ほど説明した通り、アカウントを持っていなくてもAPI経由で情報を引っ張れてしまいます。
$ curl -s https://サーバ名/api/notes -H 'content-type: application/json' --data-raw "{\"limit\":10}" | jq -r .[0].user.name,.[0].text
Misskeyと特定されにくいドメイン名にしておけばAPIを叩かれる可能性は低くなると思いますが、とはいえやはり不安が残ります。
結論
Basic認証を利用してGUIのWeb画面上では外部非公開のMisskeyサーバを構築できましたが、APIから情報が取れてしまう問題については簡単に解消することは難しそうでした。
問題もなにも、そもそも外部に情報がオープンになっている状態が本来は正常なので、ソースを書き換えない限り対応は難しいかもしれません。
プライベートなネットワークに構築してVPNで接続に行ければいいのですが、そこまで手間をかけて構築したいものでもなかったため、今回は構築を見送ることにしました。
もしうまい回避方法があれば教えていただけますと幸いです。
Discussion